[{"data":1,"prerenderedAt":761},["ShallowReactive",2],{"blog-\u002F2026-09-26-from-async-to-sync":3},{"id":4,"title":5,"author":6,"body":9,"date":751,"description":752,"extension":753,"image":754,"meta":755,"navigation":756,"path":757,"seo":758,"stem":759,"__hash__":760},"blog\u002F2026-09-26-from-async-to-sync.md","From Async to Sync",{"name":7,"url":8},"Alexander Medvedev","https:\u002F\u002Fgithub.com\u002FSnowiiii",{"type":10,"value":11,"toc":740},"minimark",[12,16,22,30,33,43,46,49,54,60,75,83,86,93,98,101,120,126,129,131,135,144,237,242,245,298,301,307,310,320,324,330,333,378,381,384,386,393,396,407,413,427,430,438,445,455,458,476,479,481,489,492,495,505,546,549,551,555,558,561,604,610,612,616,619,623,626,630,637,661,663,667,670,707,709,713,716,721,727,736],[13,14,15],"p",{},"If you spend any time in the modern Rust ecosystem, you will hear a universal gospel preached across every blog post, Reddit thread, and Discord server:",[13,17,18],{},[19,20,21],"em",{},"\"Use Tokio. Make it async. Async Rust is blazingly fast.\"",[13,23,24,25,29],{},"When we first started building Pumpkin, we fell for this trap hook, line, and sinker. The reasoning felt airtight at the time: Pumpkin is a high-performance Minecraft server written from scratch in Rust. We wanted it to handle thousands of entities, dozens of players, and massive worlds without breaking a sweat. Tokio handles millions of concurrent web connections—so naturally, making everything in Pumpkin ",[26,27,28],"code",{},"async"," had to be the secret sauce for raw performance. Right?",[13,31,32],{},"Wrong. In fact, it was probably the single worst architectural decision we made in the early days of the project.",[13,34,35,36,38,39,42],{},"Over the last few months, we undertook a massive, grueling, four-part refactor to rip ",[26,37,28],{}," and ",[26,40,41],{},".await"," completely out of our game loop, our entity ticking, our inventory management, and our commands.",[13,44,45],{},"Here is the honest story of why async game loops are a trap, why Tokio and Rayon solve two completely opposite problems, and how moving from async to synchronous ticking made Pumpkin faster, cleaner, and drastically more stable.",[47,48],"hr",{},[50,51,53],"h3",{"id":52},"async-fast","\"Async = Fast\"",[13,55,56,57,59],{},"The fundamental mistake so many Rust developers make (and the one we made) comes from misunderstanding what ",[26,58,28],{}," is actually designed to do.",[13,61,62,63,66,67,70,71,74],{},"In web backends, microservices, and proxy servers, asynchronous runtimes like Tokio are unbeatable. Why? Because web servers spend 99% of their lifespan doing absolutely nothing. A request comes in, the server fires off a SQL query or reads a file from disk, and then it waits. While waiting on network sockets or disk I\u002FO, the operating system thread doesn't need to sit idle. Tokio uses non-blocking I\u002FO primitives (",[26,64,65],{},"epoll"," on Linux, ",[26,68,69],{},"kqueue"," on macOS, ",[26,72,73],{},"io_uring",") so a tiny handful of worker threads can juggle tens of thousands of idle connections concurrently.",[13,76,77,78,82],{},"That is ",[79,80,81],"strong",{},"I\u002FO-bound concurrency",".",[13,84,85],{},"A game server, however, is a completely different beast.",[13,87,88,89,92],{},"A Minecraft server operates on a fixed heartbeat—20 ticks per second. That gives you exactly ",[79,90,91],{},"50 milliseconds"," per tick to update the entire universe. And here is the crucial realization we completely missed at the beginning:",[13,94,95],{},[79,96,97],{},"By the time a game tick starts, all network packets are already buffered in memory.",[13,99,100],{},"During those 50 milliseconds, you aren't making HTTP requests. You aren't waiting on a database query. You aren't waiting on slow network packets. You are:",[102,103,104,108,111,114,117],"ul",{},[105,106,107],"li",{},"Looping over hundreds of living mobs.",[105,109,110],{},"Computing bounding box collisions.",[105,112,113],{},"Running pathfinding algorithms and line-of-sight raycasts.",[105,115,116],{},"Updating physics, velocity, and gravity.",[105,118,119],{},"Checking crafting recipes and slot states.",[13,121,122,123,82],{},"Every single one of these tasks is ",[79,124,125],{},"100% CPU-bound",[13,127,128],{},"When you write CPU-bound simulation code inside Tokio tasks, you aren't doing non-blocking I\u002FO. You are asking an asynchronous event loop to schedule raw computation. And that is where the nightmare begins.",[47,130],{},[50,132,134],{"id":133},"tokio-vs-rayon","Tokio vs. Rayon",[13,136,137,138,38,141,82],{},"To understand why this hurt us so badly, you have to look at the architectural difference between ",[79,139,140],{},"Tokio",[79,142,143],{},"Rayon",[145,146,147,162],"table",{},[148,149,150],"thead",{},[151,152,153,158,160],"tr",{},[154,155,157],"th",{"align":156},"left","Aspect",[154,159,140],{"align":156},[154,161,143],{"align":156},[163,164,165,179,195,211,224],"tbody",{},[151,166,167,173,176],{},[168,169,170],"td",{"align":156},[79,171,172],{},"Primary Goal",[168,174,175],{"align":156},"Asynchronous I\u002FO multiplexing",[168,177,178],{"align":156},"Data-parallel CPU computation",[151,180,181,186,192],{},[168,182,183],{"align":156},[79,184,185],{},"Work Unit",[168,187,188,191],{"align":156},[26,189,190],{},"Future"," \u002F Asynchronous Task",[168,193,194],{"align":156},"Work-stealing task \u002F Iterator chunk",[151,196,197,202,208],{},[168,198,199],{"align":156},[79,200,201],{},"Yield Mechanism",[168,203,204,205,207],{"align":156},"Explicit ",[26,206,41],{}," on I\u002FO or timers",[168,209,210],{"align":156},"Work-stealing across physical cores",[151,212,213,218,221],{},[168,214,215],{"align":156},[79,216,217],{},"Allocation Cost",[168,219,220],{"align":156},"Future state-machines, task queues",[168,222,223],{"align":156},"Zero allocation across stack slices",[151,225,226,231,234],{},[168,227,228],{"align":156},[79,229,230],{},"Ideal Workload",[168,232,233],{"align":156},"Sockets, WebSockets, DB queries, Timers",[168,235,236],{"align":156},"Matrix math, physics, pathfinding, raycasts",[238,239,241],"h4",{"id":240},"ticking-entities-with-tokio","Ticking Entities with Tokio",[13,243,244],{},"At our worst point, we were literally spawning Tokio tasks for entities:",[246,247,252],"pre",{"className":248,"code":249,"language":250,"meta":251,"style":251},"language-rust shiki shiki-themes github-light github-dark","\u002F\u002F The naive async past: Spawning Tokio tasks per entity\nfor entity in entities_to_tick.iter() {\n    let e = entity.clone();\n    tasks.spawn(async move {\n        e.tick().await;\n    });\n}\n","rust","",[26,253,254,262,268,274,280,286,292],{"__ignoreMap":251},[255,256,259],"span",{"class":257,"line":258},"line",1,[255,260,261],{},"\u002F\u002F The naive async past: Spawning Tokio tasks per entity\n",[255,263,265],{"class":257,"line":264},2,[255,266,267],{},"for entity in entities_to_tick.iter() {\n",[255,269,271],{"class":257,"line":270},3,[255,272,273],{},"    let e = entity.clone();\n",[255,275,277],{"class":257,"line":276},4,[255,278,279],{},"    tasks.spawn(async move {\n",[255,281,283],{"class":257,"line":282},5,[255,284,285],{},"        e.tick().await;\n",[255,287,289],{"class":257,"line":288},6,[255,290,291],{},"    });\n",[255,293,295],{"class":257,"line":294},7,[255,296,297],{},"}\n",[13,299,300],{},"Think about what this actually does under the hood.",[13,302,303,304,306],{},"Every single tick, for every single entity in the world, Tokio has to allocate a ",[26,305,190],{}," state machine, push it onto a task queue, notify worker threads, and context-switch into it. When an entity finishes updating its coordinates 4 nanoseconds later, Tokio has to clean up that task and coordinate its completion.",[13,308,309],{},"We were spending more CPU cycles inside Tokio's runtime scheduler managing task queues and waking futures than the actual entities spent calculating their physics!",[13,311,312,313,315,316,319],{},"Even worse: Tokio worker threads rely on tasks voluntarily yielding via ",[26,314,41],{},". If an entity pathfinding calculation takes 2 milliseconds of pure CPU crunching without awaiting an I\u002FO event, it ",[79,317,318],{},"starves the Tokio worker thread",". Other tasks queued on that thread—including reading incoming TCP packets from real players—get blocked behind it!",[238,321,323],{"id":322},"what-rayon-does-instead","What Rayon Does Instead",[13,325,326,327,329],{},"Rayon doesn't care about futures, event loops, or ",[26,328,41],{},". Rayon is a pure data-parallel work-stealing thread pool.",[13,331,332],{},"Rayon spins up a thread pool that matches your physical CPU cores. When you give it a slice of entities, it divides the slice among available cores:",[246,334,336],{"className":248,"code":335,"language":250,"meta":251,"style":251},"\u002F\u002F The synchronous present: Straightforward Rayon data parallelism\ntickable\n    .par_chunks(ENTITY_TICK_BATCH_SIZE)\n    .for_each(|batch| {\n        for (entity, _) in batch {\n            entity.tick(entity.as_ref(), server_ref);\n        }\n    });\n",[26,337,338,343,348,353,358,363,368,373],{"__ignoreMap":251},[255,339,340],{"class":257,"line":258},[255,341,342],{},"\u002F\u002F The synchronous present: Straightforward Rayon data parallelism\n",[255,344,345],{"class":257,"line":264},[255,346,347],{},"tickable\n",[255,349,350],{"class":257,"line":270},[255,351,352],{},"    .par_chunks(ENTITY_TICK_BATCH_SIZE)\n",[255,354,355],{"class":257,"line":276},[255,356,357],{},"    .for_each(|batch| {\n",[255,359,360],{"class":257,"line":282},[255,361,362],{},"        for (entity, _) in batch {\n",[255,364,365],{"class":257,"line":288},[255,366,367],{},"            entity.tick(entity.as_ref(), server_ref);\n",[255,369,370],{"class":257,"line":294},[255,371,372],{},"        }\n",[255,374,376],{"class":257,"line":375},8,[255,377,291],{},[13,379,380],{},"There are no heap-allocated state machines. There is no runtime polling. There are no yield points. The threads just burn through the batches sequentially on the CPU with fantastic L1\u002FL2 cache locality. If core 3 finishes its chunk of 16 zombies before core 2 finishes its creepers, core 3 instantly steals work from core 2's queue.",[13,382,383],{},"It is direct, unadulterated CPU throughput.",[47,385],{},[50,387,389,390],{"id":388},"wrapping-everything-in-arc","Wrapping Everything in Arc",[391,392],"mutex",{},[13,394,395],{},"The performance penalty of Tokio tasks was bad, but the damage async did to our codebase architecture was even worse.",[13,397,398,399,402,403,406],{},"In Rust, the borrow checker is your best friend. If you have synchronous code, you can pass a ",[26,400,401],{},"&mut World"," or ",[26,404,405],{},"&mut Entity"," down the stack, update whatever you need, and the compiler guarantees at compile time that nothing else is mutating it. Zero runtime overhead.",[13,408,409,410,412],{},"The moment you mark a function as ",[26,411,28],{},", you lose this superpower.",[13,414,415,416,419,420,422,423,426],{},"Because an ",[26,417,418],{},"async fn"," can yield at an ",[26,421,41],{}," point and resume on a completely different OS thread, you cannot easily hold a normal mutable reference across that boundary without running into ",[26,424,425],{},"!Send"," compile errors.",[13,428,429],{},"So what do developers do when the compiler screams at them?",[13,431,432],{},[79,433,434,435,82],{},"They wrap everything in ",[26,436,437],{},"Arc\u003CMutex\u003CT>>",[13,439,440,441,444],{},"It started innocently with ",[26,442,443],{},"Arc\u003CMutex\u003CPlayer>>",". But async is a virus. If player interaction is async, then opening a chest is async. If opening a chest is async, then accessing a slot is async.",[13,446,447,448,82],{},"At one point in Pumpkin, we literally had ",[79,449,450,451,454],{},"per-slot ",[26,452,453],{},"Arc\u003CMutex\u003CItemStack>>"," in player inventories",[13,456,457],{},"Just imagine what happened when a player crafted an item or sorted a chest:",[459,460,461,464,467,470,473],"ol",{},[105,462,463],{},"Lock slot 0 asynchronously.",[105,465,466],{},"Await lock on slot 1.",[105,468,469],{},"Await lock on slot 2.",[105,471,472],{},"Risk lock contention with the main tick thread or another packet handler.",[105,474,475],{},"Pray you don't hit an async deadlock because two tasks acquired slot locks in reverse order.",[13,477,478],{},"It was madness. Reading a simple inventory required locking dozens of individual mutexes across multiple async boundaries. What should have been a 5-nanosecond array lookup turned into a festival of atomic CAS operations, thread parking, and cache line invalidation.",[47,480],{},[50,482,484,485],{"id":483},"async_trait-box-and-compile-hell","async_trait, Box",[486,487,488],"pin",{},", and Compile Hell",[13,490,491],{},"It wasn't just runtime performance that suffered—the developer experience became completely miserable.",[13,493,494],{},"For a long time, contributors rightfully complained about how painful it was to work on Pumpkin. Want to add a simple command? Want to implement a new entity goal, or handle a block interaction? You had to wade through thick layers of async boilerplate, confusing lifetime annotations, and compiler errors that stretched across three terminal screens.",[13,496,497,498,500,501,504],{},"And then came the dynamic dispatch problem. Because Rust didn't support native ",[26,499,418],{}," in traits for ",[26,502,503],{},"dyn Trait"," objects, we had to figure out how to make our entity, block, and command traits async.",[459,506,507,523],{},[105,508,509,516,519,520,522],{},[79,510,511,512,515],{},"First, we slapped ",[26,513,514],{},"#[async_trait]"," on everything.",[517,518],"br",{},"\nAt first, this felt like an easy fix. But as the codebase grew to hundreds of entities, blocks, and packet types, ",[26,521,514],{}," brought our compile times to their knees. Every single trait method was expanding into proc-macro-generated code, boxing futures behind the scenes, and blowing up LLVM codegen times. Clean builds took an eternity, and incremental compilation ground to a crawl. Contributors were waiting minutes just to see if a tiny one-line change compiled.",[105,524,525,531,533,534,543,545],{},[79,526,527,528,82],{},"Then, we tried to escape it with manual ",[26,529,530],{},"Box\u003CPin\u003Cdyn Future\u003C...>>>",[517,532],{},"\nIn a desperate attempt to shave off macro overhead and regain control over compilation times, we started manually desugaring trait return types. Suddenly, almost every trait method in Pumpkin was littered with this hideous monstrosity:",[246,535,537],{"className":248,"code":536,"language":250,"meta":251,"style":251},"fn tick\u003C'a>(&'a mut self, world: &'a World) -> Pin\u003CBox\u003Cdyn Future\u003COutput = ()> + Send + 'a>>\n",[26,538,539],{"__ignoreMap":251},[255,540,541],{"class":257,"line":258},[255,542,536],{},[517,544],{},"If you have ever tried explaining that signature to a new open-source contributor who just wanted to add a simple mob mechanic, you know the feeling of pure despair. Writing new features felt like playing 4D chess against the borrow checker and the type system while blindfolded. New contributors would open a PR, get buried under unreadable lifetime diagnostics and trait bound mismatches, get frustrated, and leave.",[13,547,548],{},"Our architecture was actively driving contributors away. That was our biggest wake-up call: we couldn't just keep patching over it with clever macros or manual pin-boxing. The design was fundamentally broken from the beginning, and async had no business being in those traits.",[47,550],{},[50,552,554],{"id":553},"four-parts-from-async-to-sync","Four Parts from Async to Sync",[13,556,557],{},"By mid-2026, we stopped making excuses. We realized that no amount of profiling or micro-optimization would fix the fact that our core architecture was fighting the hardware.",[13,559,560],{},"We sat down and systematically dismantled the async loop across four major milestones:",[459,562,563,579,589,598],{},[105,564,565,572,573,575,576,82],{},[79,566,567,568,571],{},"Part 1: The Core Game Loop (",[26,569,570],{},"World::tick","):"," We converted the world tick, player ticking, and entity loops from ",[26,574,418],{}," to plain, synchronous ",[26,577,578],{},"fn",[105,580,581,584,585,588],{},[79,582,583],{},"Part 2: Entities & Projectiles:"," We refactored all entity goals, AI routines, and projectile movements to synchronous execution. No more ",[26,586,587],{},"JoinSet"," allocations for mob updates.",[105,590,591,594,595,597],{},[79,592,593],{},"Part 3: Inventories & Commands:"," We completely eliminated the per-slot ",[26,596,453],{}," locks. Inventories moved to clean, container-level synchronization with synchronous screen handlers. Commands no longer return futures.",[105,599,600,603],{},[79,601,602],{},"Part 4: Block Entities & World Actions:"," Furnaces, chests, brewing stands, and hoppers were transitioned to synchronous ticks.",[13,605,606,607],{},"Alongside this, we made another critical move: ",[79,608,609],{},"offloading chunk serialization, region compression, and packet encoding completely off Tokio workers and onto dedicated Rayon pools.",[47,611],{},[50,613,615],{"id":614},"where-tokio-and-rayon-belong","Where Tokio and Rayon Belong",[13,617,618],{},"We didn't delete Tokio from Pumpkin. Tokio is still an essential, beloved foundation of our server. But we finally drew a firm, uncrossable line between where Tokio belongs and where Rayon belongs:",[238,620,622],{"id":621},"tokio-at-the-edge","Tokio at the Edge",[13,624,625],{},"Tokio handles the raw network connections. It listens on TCP and UDP sockets, negotiates TLS, handles Bedrock RakNet packets, performs Mojang\u002FBedrock authentication over HTTP, and streams raw bytes. When a packet arrives, Tokio decodes the header and places the raw packet into an in-memory queue.",[238,627,629],{"id":628},"rayon-in-the-game-loop","Rayon in the Game Loop",[13,631,632,633,636],{},"When the dedicated 50ms tick timer fires, the server ticker executes ",[79,634,635],{},"synchronously",":",[102,638,639,642,648,658],{},[105,640,641],{},"It pulls queued packets from memory.",[105,643,644,645,82],{},"It executes entity ticks in parallel batches of 16 using Rayon's ",[26,646,647],{},"par_chunks",[105,649,650,651,38,654,657],{},"It processes player movements, collisions, block placements, and crafting recipes using plain Rust references (",[26,652,653],{},"&",[26,655,656],{},"&mut",").",[105,659,660],{},"Once finished, outgoing packets are pushed to network buffers for Tokio to flush asynchronously to the sockets.",[47,662],{},[50,664,666],{"id":665},"what-we-gained","What We Gained",[13,668,669],{},"The results of this transition blew away even our most optimistic expectations:",[102,671,672,678,688,694],{},[105,673,674,677],{},[79,675,676],{},"Massive Drop in Tick Duration:"," Ticking hundreds of entities no longer fluctuates wildly. The scheduler overhead disappeared, reducing baseline entity tick times by several orders of magnitude.",[105,679,680,683,684,687],{},[79,681,682],{},"Zero Mutex Contention:"," By moving inventory and entity state to synchronous container-level ownership, we eliminated hundreds of ",[26,685,686],{},"Arc\u003CMutex>"," locks. Code that used to spend milliseconds waiting on lock contention now executes in nanoseconds.",[105,689,690,693],{},[79,691,692],{},"Predictable 20 TPS:"," The game loop no longer starves when complex pathfinding or physics calculations run. CPU-heavy work is executed across Rayon's worker threads without blocking incoming network packets.",[105,695,696,699,700,702,703,706],{},[79,697,698],{},"Saner, Cleaner Code:"," We deleted thousands of lines of boilerplate. Gone are the endless ",[26,701,41],{}," calls, ",[26,704,705],{},"ArcSwap"," acrobatics, and complex async trait wrappers. The codebase looks like idiomatic, clean Rust again.",[47,708],{},[50,710,712],{"id":711},"dont-cargo-cult-async","Don't Cargo-Cult Async",[13,714,715],{},"If you are building a game server, an emulator, a physics engine, or any real-time simulation in Rust, take this as our cautionary tale:",[13,717,718],{},[79,719,720],{},"Do not cargo-cult async.",[13,722,723,724,726],{},"Async Rust is an extraordinary tool for multiplexing I\u002FO over network sockets and file handles. But the moment you let ",[26,725,28],{}," leak into your core simulation loop, you are paying a massive tax in runtime allocation, scheduler overhead, and lock proliferation for a benefit that doesn't exist.",[13,728,729,730,732,733,735],{},"Use ",[79,731,140],{}," for the wire. Use ",[79,734,143],{}," for the world. Keep your game loop synchronous, let the CPU do what it does best, and trust the borrow checker.",[737,738,739],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":251,"searchDepth":264,"depth":264,"links":741},[742,743,744,745,747,748,749,750],{"id":52,"depth":270,"text":53},{"id":133,"depth":270,"text":134},{"id":388,"depth":270,"text":389},{"id":483,"depth":270,"text":746},"async_trait, Box, and Compile Hell",{"id":553,"depth":270,"text":554},{"id":614,"depth":270,"text":615},{"id":665,"depth":270,"text":666},{"id":711,"depth":270,"text":712},"2026-09-26","Many Rust developers assume async is always faster. In Pumpkin, using Tokio for entity ticking and game logic was one of our biggest early mistakes. Here is why we rewrote the core loop to be synchronous—and how Rayon changed everything.","md",null,{},true,"\u002F2026-09-26-from-async-to-sync",{"title":5,"description":752},"2026-09-26-from-async-to-sync","Lh-xFGIyn-iNCZwVCNmh0MFkxtr7-IJ04FiJC4z4j7Y",1790518538627]