[{"data":1,"prerenderedAt":1015},["ShallowReactive",2],{"page-\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Ffinding-blocking-calls-with-asyncio-debug-mode\u002F":3},{"id":4,"title":5,"body":6,"description":981,"extension":982,"meta":983,"navigation":119,"path":1011,"seo":1012,"stem":1013,"__hash__":1014},"content\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Ffinding-blocking-calls-with-asyncio-debug-mode\u002Findex.md","Finding Blocking Calls with asyncio Debug Mode",{"type":7,"value":8,"toc":970},"minimark",[9,26,31,51,55,58,93,103,160,163,166,230,236,365,369,376,516,520,523,581,584,587,591,594,613,622,628,642,648,652,708,712,715,728,749,752,798,813,817,828,838,848,927,931,960,966],[10,11,12,13,17,18,21,22,25],"p",{},"An async service handles ten requests per second and its CPU sits at eight percent. Nothing looks overloaded, latency is terrible, and every profile looks flat. The usual cause is a single synchronous call somewhere in a coroutine — a ",[14,15,16],"code",{},"psycopg2"," query, a ",[14,19,20],{},"requests.get",", a ",[14,23,24],{},"bcrypt"," hash — that occupies the loop's thread and stops every other task from running while it completes.",[27,28,30],"h2",{"id":29},"prerequisites","Prerequisites",[32,33,34,42],"ul",{},[35,36,37,41],"li",{},[38,39,40],"strong",{},"Python 3.8+","; the debug-mode behaviour described here is stable through 3.13.",[35,43,44,45,50],{},"The loop model from ",[46,47,49],"a",{"href":48},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002F","debugging async code and event loops",".",[27,52,54],{"id":53},"solution","Solution",[10,56,57],{},"Debug mode times every callback and logs the slow ones with a traceback:",[59,60,65],"pre",{"className":61,"code":62,"language":63,"meta":64,"style":64},"language-console shiki shiki-themes github-light github-dark","$ PYTHONASYNCIODEBUG=1 python -m myapp.server\nWARNING:asyncio:Executing \u003CTask pending name='Task-42'\n  coro=\u003Chandle_request() running at server.py:88>\n  created at server.py:61> took 0.412 seconds\n","console","",[14,66,67,75,81,87],{"__ignoreMap":64},[68,69,72],"span",{"class":70,"line":71},"line",1,[68,73,74],{},"$ PYTHONASYNCIODEBUG=1 python -m myapp.server\n",[68,76,78],{"class":70,"line":77},2,[68,79,80],{},"WARNING:asyncio:Executing \u003CTask pending name='Task-42'\n",[68,82,84],{"class":70,"line":83},3,[68,85,86],{},"  coro=\u003Chandle_request() running at server.py:88>\n",[68,88,90],{"class":70,"line":89},4,[68,91,92],{},"  created at server.py:61> took 0.412 seconds\n",[10,94,95,98,99,102],{},[14,96,97],{},"0.412 seconds"," on a callback is the whole diagnosis: something inside ",[14,100,101],{},"handle_request"," ran synchronously for four hundred milliseconds, and during that time the loop did nothing else. The default threshold is 100 ms, which is far too coarse for a latency-sensitive service — lower it and the smaller stalls appear:",[59,104,108],{"className":105,"code":106,"language":107,"meta":64,"style":64},"language-python shiki shiki-themes github-light github-dark","import asyncio\n\nasync def main() -> None:\n    loop = asyncio.get_running_loop()\n    loop.set_debug(True)\n    loop.slow_callback_duration = 0.02      # warn on anything over 20 ms\n    await serve()\n\nasyncio.run(main(), debug=True)\n","python",[14,109,110,115,121,126,131,137,143,149,154],{"__ignoreMap":64},[68,111,112],{"class":70,"line":71},[68,113,114],{},"import asyncio\n",[68,116,117],{"class":70,"line":77},[68,118,120],{"emptyLinePlaceholder":119},true,"\n",[68,122,123],{"class":70,"line":83},[68,124,125],{},"async def main() -> None:\n",[68,127,128],{"class":70,"line":89},[68,129,130],{},"    loop = asyncio.get_running_loop()\n",[68,132,134],{"class":70,"line":133},5,[68,135,136],{},"    loop.set_debug(True)\n",[68,138,140],{"class":70,"line":139},6,[68,141,142],{},"    loop.slow_callback_duration = 0.02      # warn on anything over 20 ms\n",[68,144,146],{"class":70,"line":145},7,[68,147,148],{},"    await serve()\n",[68,150,152],{"class":70,"line":151},8,[68,153,120],{"emptyLinePlaceholder":119},[68,155,157],{"class":70,"line":156},9,[68,158,159],{},"asyncio.run(main(), debug=True)\n",[10,161,162],{},"Debug mode also reports coroutines that were never awaited, tasks destroyed while pending, and non-threadsafe calls from the wrong thread — the same switch covers all four, which is why it belongs on permanently in the test suite.",[10,164,165],{},"Once the offending call is identified, the fix is to move it off the loop thread:",[59,167,169],{"className":105,"code":168,"language":107,"meta":64,"style":64},"import asyncio\nimport functools\n\nasync def hash_password(password: str) -> bytes:\n    loop = asyncio.get_running_loop()\n    # bcrypt is CPU-bound and synchronous: run it in the default thread pool.\n    return await loop.run_in_executor(None, functools.partial(bcrypt_hash, password))\n\nasync def fetch_rows(dsn: str, query: str) -> list:\n    # Better: use an async driver so there is no thread hop at all.\n    async with asyncpg.create_pool(dsn) as pool:\n        return await pool.fetch(query)\n",[14,170,171,175,180,184,189,193,198,203,207,212,218,224],{"__ignoreMap":64},[68,172,173],{"class":70,"line":71},[68,174,114],{},[68,176,177],{"class":70,"line":77},[68,178,179],{},"import functools\n",[68,181,182],{"class":70,"line":83},[68,183,120],{"emptyLinePlaceholder":119},[68,185,186],{"class":70,"line":89},[68,187,188],{},"async def hash_password(password: str) -> bytes:\n",[68,190,191],{"class":70,"line":133},[68,192,130],{},[68,194,195],{"class":70,"line":139},[68,196,197],{},"    # bcrypt is CPU-bound and synchronous: run it in the default thread pool.\n",[68,199,200],{"class":70,"line":145},[68,201,202],{},"    return await loop.run_in_executor(None, functools.partial(bcrypt_hash, password))\n",[68,204,205],{"class":70,"line":151},[68,206,120],{"emptyLinePlaceholder":119},[68,208,209],{"class":70,"line":156},[68,210,211],{},"async def fetch_rows(dsn: str, query: str) -> list:\n",[68,213,215],{"class":70,"line":214},10,[68,216,217],{},"    # Better: use an async driver so there is no thread hop at all.\n",[68,219,221],{"class":70,"line":220},11,[68,222,223],{},"    async with asyncpg.create_pool(dsn) as pool:\n",[68,225,227],{"class":70,"line":226},12,[68,228,229],{},"        return await pool.fetch(query)\n",[10,231,232,235],{},[14,233,234],{},"run_in_executor"," is the general escape hatch and costs a thread hop per call. An async-native library is better where one exists, because it removes the blocking entirely rather than relocating it.",[237,238,241,361],"figure",{"className":239},[240],"diagram",[242,243,250,251,250,255,250,259,250,267,250,276,250,285,250,291,250,295,250,299,250,304,250,309,250,312,250,315,250,318,250,322,250,325,250,328,250,331,250,335,250,338,250,341,250,344,250,348,250,351,250,354,250,358],"svg",{"viewBox":244,"role":245,"ariaLabelledBy":246,"xmlns":249},"0 0 760 270","img",[247,248],"blockkinds-t","blockkinds-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[252,253,254],"title",{"id":247},"Four kinds of blocking and their fixes",[256,257,258],"desc",{"id":248},"A table of four blocking sources in async code - a synchronous I\u002FO library, CPU-bound work, time.sleep, and a large parse or serialization - with the symptom and the correct remedy for each.",[260,261],"rect",{"x":262,"y":262,"width":263,"height":264,"rx":265,"fill":266},"0","760","270","14","#fffdf8",[268,269,254],"text",{"x":270,"y":271,"textAnchor":272,"fontSize":273,"fontWeight":274,"fill":275},"380","30","middle","15","700","#3d405b",[260,277],{"x":278,"y":279,"width":280,"height":281,"rx":282,"fill":283,"stroke":275,"strokeWidth":284},"24","52","712","40","10","#f2cc8f","1.5",[268,286,290],{"x":287,"y":288,"fontSize":289,"fontWeight":274,"fill":275},"38","76","12","Criterion",[268,292,294],{"x":293,"y":288,"textAnchor":272,"fontSize":289,"fontWeight":274,"fill":275},"390","Symptom",[268,296,298],{"x":297,"y":288,"textAnchor":272,"fontSize":289,"fontWeight":274,"fill":275},"620","Fix",[260,300],{"x":278,"y":301,"width":280,"height":281,"fill":302,"stroke":275,"strokeWidth":303},"92","#f4f1de","1",[268,305,308],{"x":287,"y":306,"fontSize":307,"fill":275},"116","11.5","sync I\u002FO library",[268,310,311],{"x":293,"y":306,"textAnchor":272,"fontSize":307,"fill":275},"loop lag during requests",[268,313,314],{"x":297,"y":306,"textAnchor":272,"fontSize":307,"fill":275},"async driver, or executor",[260,316],{"x":278,"y":317,"width":280,"height":281,"fill":266,"stroke":275,"strokeWidth":303},"132",[268,319,321],{"x":287,"y":320,"fontSize":307,"fill":275},"156","CPU-bound work",[268,323,324],{"x":293,"y":320,"textAnchor":272,"fontSize":307,"fill":275},"steady lag under load",[268,326,327],{"x":297,"y":320,"textAnchor":272,"fontSize":307,"fill":275},"process pool",[260,329],{"x":278,"y":330,"width":280,"height":281,"fill":302,"stroke":275,"strokeWidth":303},"172",[268,332,334],{"x":287,"y":333,"fontSize":307,"fill":275},"196","time.sleep in a coroutine",[268,336,337],{"x":293,"y":333,"textAnchor":272,"fontSize":307,"fill":275},"fixed stalls",[268,339,340],{"x":297,"y":333,"textAnchor":272,"fontSize":307,"fill":275},"await asyncio.sleep",[260,342],{"x":278,"y":343,"width":280,"height":281,"fill":266,"stroke":275,"strokeWidth":303},"212",[268,345,347],{"x":287,"y":346,"fontSize":307,"fill":275},"236","huge JSON parse",[268,349,350],{"x":293,"y":346,"textAnchor":272,"fontSize":307,"fill":275},"spiky lag on big payloads",[268,352,353],{"x":297,"y":346,"textAnchor":272,"fontSize":307,"fill":275},"thread pool, or stream it",[70,355],{"x1":356,"y1":279,"x2":356,"y2":357,"stroke":275,"strokeWidth":303},"274","252",[70,359],{"x1":360,"y1":279,"x2":360,"y2":357,"stroke":275,"strokeWidth":303},"505",[362,363,364],"figcaption",{},"Thread pools solve I\u002FO blocking; only a process pool helps with CPU-bound work, because threads still contend for the GIL.",[27,366,368],{"id":367},"why-this-works","Why this works",[10,370,371,372,375],{},"An event loop is a single thread running callbacks one at a time. Every ",[14,373,374],{},"await"," yields control back to the loop, which is how concurrency happens; a call that does not yield holds the thread until it returns. Debug mode wraps callback execution with a timer and logs any step exceeding the threshold, along with the coroutine's creation traceback — which is the part that makes the warning actionable, because the stall is reported with the source location where the task was created rather than only where it was running.",[237,377,379,513],{"className":378},[240],[242,380,250,385,250,388,250,391,250,420,250,423,250,425,250,430,250,434,250,440,250,442,250,445,250,448,250,451,250,455,250,458,250,464,250,470,250,474,250,480,250,489,250,494,250,497,250,501,250,504,250,508],{"viewBox":381,"role":245,"ariaLabelledBy":382,"xmlns":249},"0 0 760 430",[383,384],"loopblock-t","loopblock-d",[252,386,387],{"id":383},"What a blocking call does to the loop",[256,389,390],{"id":384},"A sequence diagram with three lanes: task A, the event loop, and task B. Task A runs a synchronous call that does not yield, the loop cannot dispatch anything during it, task B waits in the ready queue, and only when task A finally returns does task B get its turn.",[392,393,394,395,394,408,394,414,250],"defs",{},"\n    ",[396,397,404],"marker",{"id":398,"viewBox":399,"refX":400,"refY":401,"markerWidth":402,"markerHeight":402,"orient":403},"loopblock-a","0 0 10 10","9","5","7","auto-start-reverse",[405,406],"path",{"d":407,"fill":275},"M0 0 L10 5 L0 10 z",[396,409,411],{"id":410,"viewBox":399,"refX":400,"refY":401,"markerWidth":402,"markerHeight":402,"orient":403},"loopblock-c",[405,412],{"d":407,"fill":413},"#e07a5f",[396,415,417],{"id":416,"viewBox":399,"refX":400,"refY":401,"markerWidth":402,"markerHeight":402,"orient":403},"loopblock-s",[405,418],{"d":407,"fill":419},"#81b29a",[260,421],{"x":262,"y":262,"width":263,"height":422,"rx":265,"fill":266},"430",[268,424,387],{"x":270,"y":271,"textAnchor":272,"fontSize":273,"fontWeight":274,"fill":275},[260,426],{"x":427,"y":279,"width":428,"height":281,"rx":282,"fill":302,"stroke":275,"strokeWidth":429},"34","220","1.8",[268,431,433],{"x":432,"y":288,"textAnchor":272,"fontSize":289,"fontWeight":274,"fill":275},"144","task A",[70,435],{"x1":432,"y1":436,"x2":432,"y2":437,"stroke":275,"strokeWidth":438,"strokeDashArray":439},"98","414","1.2",[401,401],[260,441],{"x":264,"y":279,"width":428,"height":281,"rx":282,"fill":302,"stroke":275,"strokeWidth":429},[268,443,444],{"x":270,"y":288,"textAnchor":272,"fontSize":289,"fontWeight":274,"fill":275},"event loop",[70,446],{"x1":270,"y1":436,"x2":270,"y2":437,"stroke":275,"strokeWidth":438,"strokeDashArray":447},[401,401],[260,449],{"x":450,"y":279,"width":428,"height":281,"rx":282,"fill":302,"stroke":275,"strokeWidth":429},"506",[268,452,454],{"x":453,"y":288,"textAnchor":272,"fontSize":289,"fontWeight":274,"fill":275},"616","task B",[70,456],{"x1":453,"y1":436,"x2":453,"y2":437,"stroke":275,"strokeWidth":438,"strokeDashArray":457},[401,401],[70,459],{"x1":460,"y1":461,"x2":462,"y2":461,"stroke":275,"strokeWidth":429,"markerEnd":463},"152","126","372","url(#loopblock-a)",[268,465,469],{"x":466,"y":467,"textAnchor":272,"fontSize":468,"fill":275},"262","117","11","sync call, no await",[405,471],{"d":472,"fill":473,"stroke":275,"strokeWidth":429,"markerEnd":463},"M380 180 h 46 v 22 h -40","none",[268,475,479],{"x":476,"y":477,"textAnchor":478,"fontSize":468,"fill":275},"436","184","start","cannot dispatch",[70,481],{"x1":482,"y1":483,"x2":484,"y2":483,"stroke":413,"strokeWidth":429,"strokeDashArray":485,"markerEnd":488},"388","234","608",[486,487],"6","4","url(#loopblock-c)",[268,490,493],{"x":491,"y":492,"textAnchor":272,"fontSize":468,"fill":275},"498","225","still queued",[70,495],{"x1":460,"y1":496,"x2":462,"y2":496,"stroke":275,"strokeWidth":429,"markerEnd":463},"288",[268,498,500],{"x":466,"y":499,"textAnchor":272,"fontSize":468,"fill":275},"279","returns at last",[70,502],{"x1":482,"y1":503,"x2":484,"y2":503,"stroke":275,"strokeWidth":429,"markerEnd":463},"342",[268,505,507],{"x":491,"y":506,"textAnchor":272,"fontSize":468,"fill":275},"333","finally runs",[268,509,512],{"x":270,"y":510,"textAnchor":272,"fontSize":468,"fontStyle":511,"fill":275},"418","italic","Debug mode times exactly the middle segment and warns when it is too long.",[362,514,515],{},"Nothing is broken while this happens — the loop is simply not running, and every latency metric in the service reflects it.",[27,517,519],{"id":518},"measuring-loop-lag-directly","Measuring loop lag directly",[10,521,522],{},"Warnings tell you when a single callback was slow. A heartbeat tells you how the loop is behaving overall, under real load, and it costs almost nothing to run continuously:",[59,524,526],{"className":105,"code":525,"language":107,"meta":64,"style":64},"import asyncio\nimport time\n\nasync def loop_lag_monitor(interval: float = 0.1, warn_ms: float = 50.0) -> None:\n    \"\"\"Sleep for `interval` and report how much longer it actually took.\"\"\"\n    while True:\n        start = time.perf_counter()\n        await asyncio.sleep(interval)\n        lag_ms = (time.perf_counter() - start - interval) * 1000\n        if lag_ms > warn_ms:\n            print(f\"event loop lag: {lag_ms:.1f} ms\")\n",[14,527,528,532,537,541,546,551,556,561,566,571,576],{"__ignoreMap":64},[68,529,530],{"class":70,"line":71},[68,531,114],{},[68,533,534],{"class":70,"line":77},[68,535,536],{},"import time\n",[68,538,539],{"class":70,"line":83},[68,540,120],{"emptyLinePlaceholder":119},[68,542,543],{"class":70,"line":89},[68,544,545],{},"async def loop_lag_monitor(interval: float = 0.1, warn_ms: float = 50.0) -> None:\n",[68,547,548],{"class":70,"line":133},[68,549,550],{},"    \"\"\"Sleep for `interval` and report how much longer it actually took.\"\"\"\n",[68,552,553],{"class":70,"line":139},[68,554,555],{},"    while True:\n",[68,557,558],{"class":70,"line":145},[68,559,560],{},"        start = time.perf_counter()\n",[68,562,563],{"class":70,"line":151},[68,564,565],{},"        await asyncio.sleep(interval)\n",[68,567,568],{"class":70,"line":156},[68,569,570],{},"        lag_ms = (time.perf_counter() - start - interval) * 1000\n",[68,572,573],{"class":70,"line":214},[68,574,575],{},"        if lag_ms > warn_ms:\n",[68,577,578],{"class":70,"line":220},[68,579,580],{},"            print(f\"event loop lag: {lag_ms:.1f} ms\")\n",[10,582,583],{},"A healthy loop reports sub-millisecond lag. Sustained lag of tens of milliseconds means the loop is saturated; spikes correlated with a specific endpoint point straight at that handler. Because the measurement is a real task competing for the same loop, it reflects what user requests experience rather than what the process thinks it is doing.",[10,585,586],{},"Export the metric rather than printing it, and the same three lines become a production signal that distinguishes \"the service is slow\" from \"the loop is blocked\" — two problems with completely different fixes.",[27,588,590],{"id":589},"auditing-for-blocking-calls-before-they-ship","Auditing for blocking calls before they ship",[10,592,593],{},"Detection is better than diagnosis. Three habits catch blocking work at review time.",[10,595,596,599,600,603,604,603,606,603,609,612],{},[38,597,598],{},"Keep a list of banned imports inside async modules."," ",[14,601,602],{},"requests",", ",[14,605,16],{},[14,607,608],{},"time.sleep",[14,610,611],{},"subprocess.run"," and any synchronous SDK are all blocking; a lint rule or a simple grep in CI catches them before merge.",[59,614,616],{"className":61,"code":615,"language":63,"meta":64,"style":64},"$ grep -rn \"^import requests\\|time\\.sleep(\\|psycopg2\\.connect\" src\u002Fmyapp\u002Fasync\u002F && exit 1\n",[14,617,618],{"__ignoreMap":64},[68,619,620],{"class":70,"line":71},[68,621,615],{},[10,623,624,627],{},[38,625,626],{},"Turn the warning into a failure in tests."," With debug mode on and the threshold low, a slow-callback warning during the test suite means a blocking call reached a coroutine — configure logging so that warning fails the test rather than scrolling past.",[10,629,630,633,634,637,638,641],{},[38,631,632],{},"Name the executor boundary explicitly."," A wrapper function called ",[14,635,636],{},"blocking_*"," or a module named ",[14,639,640],{},"sync_bridge"," makes the thread hop visible in review, so nobody has to guess whether a call is safe to await.",[10,643,644,645,647],{},"The one thing not to do is spread ",[14,646,234],{}," everywhere defensively. Each call costs a thread and a hop, and a service that wraps everything has re-implemented a thread pool server with extra steps — at which point the async model is providing cost without benefit.",[27,649,651],{"id":650},"edge-cases-and-failure-modes","Edge cases and failure modes",[32,653,654,660,674,684,692,702],{},[35,655,656,659],{},[38,657,658],{},"Debug mode slows the loop measurably."," Timing every callback and capturing creation tracebacks costs a few percent; keep it on in tests and bounded in production.",[35,661,662,665,666,669,670,673],{},[38,663,664],{},"The warning names the coroutine, not the blocking line."," Combine it with a ",[14,667,668],{},"py-spy dump"," during the stall, or bisect by adding ",[14,671,672],{},"await asyncio.sleep(0)"," markers.",[35,675,676,679,680,683],{},[38,677,678],{},"A thread pool does not help CPU-bound work."," The GIL serialises it; use ",[14,681,682],{},"ProcessPoolExecutor"," or move the work out of the process.",[35,685,686,691],{},[38,687,688,690],{},[14,689,234],{}," propagates exceptions, not cancellation."," Cancelling the awaiting task does not stop the thread, which keeps running to completion.",[35,693,694,697,698,701],{},[38,695,696],{},"Blocking during startup is invisible."," Warnings only fire once the loop is running, so synchronous work in module import or in an ",[14,699,700],{},"on_startup"," hook is not reported.",[35,703,704,707],{},[38,705,706],{},"uvloop reports the same warnings"," but with slightly different thresholds and no Python-level callback wrapping in some paths; verify a suspected stall against the default loop before concluding it is loop-specific.",[27,709,711],{"id":710},"choosing-between-an-executor-and-an-async-library","Choosing between an executor and an async library",[10,713,714],{},"Once a blocking call is found, there are two fixes and they are not equivalent.",[10,716,717,719,720,723,724,727],{},[14,718,234],{}," keeps the synchronous library and moves it to a thread. It is a two-line change, it works with any library, and it costs a thread per concurrent call plus a scheduling hop. For a handful of calls per request — a password hash, an image resize, a synchronous SDK used once — it is the right answer, and the default ",[14,721,722],{},"ThreadPoolExecutor"," sized at ",[14,725,726],{},"min(32, cpu_count + 4)"," is usually adequate.",[10,729,730,731,734,735,603,737,734,740,603,742,734,745,748],{},"An async-native library removes the blocking entirely. ",[14,732,733],{},"asyncpg"," instead of ",[14,736,16],{},[14,738,739],{},"httpx",[14,741,602],{},[14,743,744],{},"aiofiles",[14,746,747],{},"open"," for large reads. The change is larger — different API, different error types, different connection management — and the payoff is that concurrency stops being bounded by a thread pool.",[10,750,751],{},"The decision is mostly about call volume. A service making one blocking call per request at ten requests per second needs ten threads and an executor is fine. The same service at a thousand requests per second needs a thousand, which is not a thread pool but a problem — and there the migration is not optional.",[59,753,755],{"className":105,"code":754,"language":107,"meta":64,"style":64},"import asyncio\nfrom concurrent.futures import ThreadPoolExecutor\n\n# An explicitly sized pool makes the bound visible rather than implicit.\n_pool = ThreadPoolExecutor(max_workers=16, thread_name_prefix=\"blocking\")\n\nasync def legacy_lookup(key: str) -> dict:\n    loop = asyncio.get_running_loop()\n    return await loop.run_in_executor(_pool, sync_client.get, key)\n",[14,756,757,761,766,770,775,780,784,789,793],{"__ignoreMap":64},[68,758,759],{"class":70,"line":71},[68,760,114],{},[68,762,763],{"class":70,"line":77},[68,764,765],{},"from concurrent.futures import ThreadPoolExecutor\n",[68,767,768],{"class":70,"line":83},[68,769,120],{"emptyLinePlaceholder":119},[68,771,772],{"class":70,"line":89},[68,773,774],{},"# An explicitly sized pool makes the bound visible rather than implicit.\n",[68,776,777],{"class":70,"line":133},[68,778,779],{},"_pool = ThreadPoolExecutor(max_workers=16, thread_name_prefix=\"blocking\")\n",[68,781,782],{"class":70,"line":139},[68,783,120],{"emptyLinePlaceholder":119},[68,785,786],{"class":70,"line":145},[68,787,788],{},"async def legacy_lookup(key: str) -> dict:\n",[68,790,791],{"class":70,"line":151},[68,792,130],{},[68,794,795],{"class":70,"line":156},[68,796,797],{},"    return await loop.run_in_executor(_pool, sync_client.get, key)\n",[10,799,800,801,804,805,808,809,812],{},"Naming the pool's threads is worth the extra argument: ",[14,802,803],{},"py-spy"," and the task dump then show ",[14,806,807],{},"blocking-3"," rather than ",[14,810,811],{},"Thread-7",", and a saturated pool is visible at a glance instead of being inferred from latency.",[27,814,816],{"id":815},"frequently-asked-questions","Frequently Asked Questions",[10,818,819,822,823,603,825,827],{},[38,820,821],{},"What counts as a blocking call in asyncio?","\nAnything that occupies the thread running the loop without awaiting: a synchronous database driver, ",[14,824,602],{},[14,826,608],{},", a large JSON parse, or a CPU-heavy loop. While it runs, no other task can make progress.",[10,829,830,833,834,837],{},[38,831,832],{},"What does the slow callback warning actually measure?","\nThe wall-clock duration of one callback or task step. Debug mode logs any step longer than ",[14,835,836],{},"loop.slow_callback_duration",", which defaults to 100 milliseconds.",[10,839,840,843,844,847],{},[38,841,842],{},"Should asyncio debug mode be enabled in production?","\nNot continuously — it adds per-callback timing and coroutine creation tracebacks. Enable it in the test suite always, and in production only for a bounded investigation window.\n",[38,845,846],{},"Does uvloop change any of this?","\nThe blocking model is identical — one thread, one callback at a time — and the slow-callback warning still fires. What differs is that uvloop's C implementation does not wrap every callback the same way, so a small number of internal paths are not timed. Diagnose a suspected stall against the default loop first, then confirm the fix under uvloop.",[237,849,851,924],{"className":850},[240],[242,852,250,856,250,859,250,862,250,864,250,866,250,868,250,870,250,873,250,876,250,878,250,881,250,884,250,887,250,889,250,892,250,895,250,898,250,900,250,903,250,906,250,909,250,911,250,914,250,917,250,920,250,922],{"viewBox":244,"role":245,"ariaLabelledBy":853,"xmlns":249},[854,855],"execchoice-t","execchoice-d",[252,857,858],{"id":854},"Executor or async library, by call volume",[256,860,861],{"id":855},"A table comparing a thread executor and an async-native library across concurrency limits, code change size, error handling and the call volume each suits.",[260,863],{"x":262,"y":262,"width":263,"height":264,"rx":265,"fill":266},[268,865,858],{"x":270,"y":271,"textAnchor":272,"fontSize":273,"fontWeight":274,"fill":275},[260,867],{"x":278,"y":279,"width":280,"height":281,"rx":282,"fill":283,"stroke":275,"strokeWidth":284},[268,869,290],{"x":287,"y":288,"fontSize":289,"fontWeight":274,"fill":275},[268,871,872],{"x":293,"y":288,"textAnchor":272,"fontSize":289,"fontWeight":274,"fill":275},"Thread executor",[268,874,875],{"x":297,"y":288,"textAnchor":272,"fontSize":289,"fontWeight":274,"fill":275},"Async library",[260,877],{"x":278,"y":301,"width":280,"height":281,"fill":302,"stroke":275,"strokeWidth":303},[268,879,880],{"x":287,"y":306,"fontSize":307,"fill":275},"Concurrency limit",[268,882,883],{"x":293,"y":306,"textAnchor":272,"fontSize":307,"fill":275},"pool size",[268,885,886],{"x":297,"y":306,"textAnchor":272,"fontSize":307,"fill":275},"the loop itself",[260,888],{"x":278,"y":317,"width":280,"height":281,"fill":266,"stroke":275,"strokeWidth":303},[268,890,891],{"x":287,"y":320,"fontSize":307,"fill":275},"Code change",[268,893,894],{"x":293,"y":320,"textAnchor":272,"fontSize":307,"fill":275},"two lines",[268,896,897],{"x":297,"y":320,"textAnchor":272,"fontSize":307,"fill":275},"a migration",[260,899],{"x":278,"y":330,"width":280,"height":281,"fill":302,"stroke":275,"strokeWidth":303},[268,901,902],{"x":287,"y":333,"fontSize":307,"fill":275},"Cancellation",[268,904,905],{"x":293,"y":333,"textAnchor":272,"fontSize":307,"fill":275},"does not stop the thread",[268,907,908],{"x":297,"y":333,"textAnchor":272,"fontSize":307,"fill":275},"propagates properly",[260,910],{"x":278,"y":343,"width":280,"height":281,"fill":266,"stroke":275,"strokeWidth":303},[268,912,913],{"x":287,"y":346,"fontSize":307,"fill":275},"Suits",[268,915,916],{"x":293,"y":346,"textAnchor":272,"fontSize":307,"fill":275},"tens of calls\u002Fsec",[268,918,919],{"x":297,"y":346,"textAnchor":272,"fontSize":307,"fill":275},"hundreds and up",[70,921],{"x1":356,"y1":279,"x2":356,"y2":357,"stroke":275,"strokeWidth":303},[70,923],{"x1":360,"y1":279,"x2":360,"y2":357,"stroke":275,"strokeWidth":303},[362,925,926],{},"The third row is the one that bites in production: a cancelled request leaves its executor thread running to completion.",[27,928,930],{"id":929},"related","Related",[32,932,933,939,946,953],{},[35,934,935,938],{},[46,936,937],{"href":48},"Debugging async code and event loops"," — task inspection and the rest of the loop toolkit.",[35,940,941,945],{},[46,942,944],{"href":943},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Fdebugging-event-loop-is-closed-runtimeerror\u002F","Debugging \"Event loop is closed\" RuntimeError"," — the lifetime failure that debug mode also reports.",[35,947,948,952],{},[46,949,951],{"href":950},"\u002Fsystematic-debugging-performance-profiling\u002Fcpu-profiling-with-cprofile-and-py-spy\u002Freading-py-spy-flame-graphs\u002F","Reading flame graphs from py-spy output"," — seeing the blocking frame in a sampled profile.",[35,954,955,959],{},[46,956,958],{"href":957},"\u002Fadvanced-pytest-architecture-configuration\u002Fmastering-pytest-fixtures\u002Fhow-to-scope-pytest-fixtures-for-async-tests\u002F","How to scope pytest fixtures for async tests"," — keeping the test loop healthy while you investigate.",[10,961,962,963],{},"← Back to ",[46,964,965],{"href":48},"Debugging Async Code and Event Loops",[967,968,969],"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":64,"searchDepth":77,"depth":77,"links":971},[972,973,974,975,976,977,978,979,980],{"id":29,"depth":77,"text":30},{"id":53,"depth":77,"text":54},{"id":367,"depth":77,"text":368},{"id":518,"depth":77,"text":519},{"id":589,"depth":77,"text":590},{"id":650,"depth":77,"text":651},{"id":710,"depth":77,"text":711},{"id":815,"depth":77,"text":816},{"id":929,"depth":77,"text":930},"Find the synchronous call stalling your event loop: asyncio debug mode slow-callback warnings, loop lag measurement, and moving blocking work to a thread pool.","md",{"slug":984,"type":985,"breadcrumb":986,"datePublished":987,"dateModified":987,"faq":988,"howto":995},"finding-blocking-calls-with-asyncio-debug-mode","article","Blocking Calls","2026-08-01",[989,991,993],{"q":821,"a":990},"Anything that occupies the thread running the loop without awaiting: a synchronous database driver, requests, time.sleep, a large JSON parse, or a CPU-heavy loop. While it runs, no other task can make progress.",{"q":832,"a":992},"The wall-clock duration of one callback or task step. Debug mode logs any step longer than loop.slow_callback_duration, which defaults to 100 milliseconds.",{"q":842,"a":994},"Not continuously — it adds per-callback timing and coroutine creation tracebacks. Enable it in the test suite always, and in production only for a bounded investigation window.",{"name":996,"description":997,"steps":998},"How to find blocking calls in asyncio code","Detect and attribute synchronous work that stalls the event loop.",[999,1002,1005,1008],{"name":1000,"text":1001},"Enable asyncio debug mode","Run with PYTHONASYNCIODEBUG or pass debug True so slow callbacks are logged.",{"name":1003,"text":1004},"Lower the slow-callback threshold","Set loop.slow_callback_duration below the default to surface smaller stalls.",{"name":1006,"text":1007},"Measure loop lag independently","Run a heartbeat task that reports scheduling delay under real load.",{"name":1009,"text":1010},"Move the blocking work off the loop","Wrap it in run_in_executor or replace the synchronous library with an async one.","\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Ffinding-blocking-calls-with-asyncio-debug-mode",{"title":5,"description":981},"systematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Ffinding-blocking-calls-with-asyncio-debug-mode\u002Findex","zBQ7TXJu6eM6OvcAfMQ8eW814sXRqkKzL6Plo47-cJI",1785613403108]