ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python无限循环发生原因与排查:从条件设计到调试技巧

Python无限循环发生原因与排查:从条件设计到调试技巧 1. 无限循环每个Python初学者都会撞上的“死胡同”如果你刚开始学Python大概率已经遇到过这种场景写了一段看起来没问题的代码运行时终端里的光标一直闪程序就是不结束CPU风扇呼呼转按CtrlC也没反应只能把窗口整个关掉。这就是无限循环也叫死循环。无限循环的本质很简单循环条件永远为真或者根本没有退出循环的出口。它不是你一个人会犯的错几乎所有Python开发者包括工作多年的人都踩过这个坑。区别只在于经验丰富的人能更快看出来问题出在哪而新手往往要折腾很久。这篇文章我想从自己实际写代码的经验出发把无限循环的产生原因、排查思路和避免方法系统地讲一遍。不是那种教科书式的罗列而是按我平时调试代码时实际会走的路径来写。你不仅能看懂“为什么会死循环”还能学会“下次怎么写能避开它”以及“万一真死循环了怎么快速定位”。先说明一下适用范围这篇文章面向的读者是刚学Python不久的初学者也适合教编程的人当备课参考。内容里不会涉及太高深的东西但会把一些容易被忽略的细节讲透。2. 无限循环产生的根本原因条件永远为真理解无限循环第一步不是背结论而是搞清楚循环到底是怎么工作的。Python里的循环主要有两种while循环和for循环。for循环通常遍历一个有限序列比如列表、字符串、range()生成的范围序列用完了循环自然结束。while循环不一样它每次迭代前会判断一个条件表达式只要条件为真就继续执行循环体。所以无限循环最直接的产生原因就是while后面的条件表达式一直为真永远没有变成False的机会。听起来很简单但实际操作中条件一直为真的方式五花八门。我把它归纳为三类条件本身就是一个不会变化的常量比如直接写while True然后循环体里没有break条件依赖的变量在循环体内没有被更新或者更新方式错误导致条件永远满足条件的判断逻辑写错了比如该用不等号的地方用了等号或者边界条件算错。2.1 常量条件最常见的“新手友好型”死循环while True这种写法本身没错很多正规代码里也会用。问题在于如果你写了while True循环体里就必须有一个break语句来跳出循环或者有return、sys.exit()这样的退出机制。否则这个循环就是字面意义上的“永远执行下去”。我见过很多初学者写的代码是这样的while True: user_input input(请输入一个数字) print(你输入的是, user_input)这段代码乍看没问题用户输入什么它就打印什么。但问题来了如果用户一直输入这个程序就永远不会结束。更麻烦的是有些初学初学者以为“程序运行中”就是“正常”直到发现控制台无法退出才意识到出了问题。while True的正确打开方式是配合退出条件while True: user_input input(请输入一个数字输入q退出) if user_input q: break print(你输入的是, user_input)这里的关键是break必须在某个条件下一定会被执行到而且这个条件在用户操作下是可触发的。如果你写的是while True但循环体里压根没有break那不管循环体里干了什么这个循环都退不出来。还有一种情况比较隐蔽break写是写了但写在永远执行不到的位置。比如while True: print(开始干活) break print(这行永远不会执行)这种代码不会死循环但它提醒我们break的位置和条件必须仔细检查。2.2 变量更新遗漏条件依赖的值从不改变第二种常见情况是循环条件依赖某个变量但循环体内忘了更新这个变量。举个例子你想从1加到100total 0 i 1 while i 100: total i这段代码跑起来就是典型的死循环。因为循环体里虽然有total i但i的值从头到尾都是1i 100永远为真于是程序无限累加total越来越大直到内存溢出或你手动终止。正确的写法是在循环体里更新itotal 0 i 1 while i 100: total i i 1这个例子太经典了几乎每本Python教程都会提。但我想说的是实际工作里“忘了更新变量”这个错误远比你想象的隐蔽。你在写复杂逻辑的时候可能更新了循环体内的某个变量但更新的是另一个无关变量真正控制循环条件的那个变量还是没变。我之前调试过一段数据处理代码原意是循环读取文件里的每一行处理完一行就跳到下一行。结果代码里用的是line file.readline()倒是每次都读了但判断循环是否继续的变量却不是line而是一个事先算好的固定长度。最后程序把同一行处理了无数遍输出了几十万条重复数据。这种错误如果不加日志肉眼真的很难发现。所以我的建议是while循环里凡是参与条件判断的变量你在心里要有个清单。每次写完循环体先问自己一句“这些变量的值在循环过程中会变吗如果不会变循环怎么结束”2.3 逻辑写错条件边界与比较运算符的坑第三类原因比前两类更难排查因为代码看起来“逻辑正确”但边界条件错了。最常见的几种情况我来逐个说。第一个坑是和用错。假设你想让循环执行10次i 0 while i 10: print(i) i 1这段没问题。但如果你写成while i 10循环会执行11次。如果后面用i去索引长度为10的列表就会触发IndexError。如果循环体里没有对i进行限制而且后续逻辑依赖这个次数就可能引发连锁错误。虽然这不算严格意义上的无限循环但它证明了边界条件的重要性。第二个坑是浮点数比较。看这个例子x 0.0 while x ! 1.0: x 0.1 print(x)表面上从0开始每次加0.1加到第10次应该正好等于1.0循环结束。但实际运行你会发现这段代码几乎必然是死循环。原因在浮点数的二进制表示上0.1在计算机里不是一个精确值而是一个近似值。多次累加之后x的值可能是0.9999999999999999也可能直接跳过1.0变成1.0000000000000002。无论哪种情况x ! 1.0的判断都会一直为真。这种问题在计算密集型程序里特别常见。我以前写数值计算脚本时用浮点数做累加来判断循环结束结果程序跑了几个小时都没停。后来学乖了浮点数比较一律用“误差范围”也就是比较两个数的差值是否小于某个极小值x 0.0 while abs(x - 1.0) 1e-9: x 0.1或者更简单直接用整数计数来控制循环次数。这个思路在后面讲“避免方法”时我会详细展开。第三个坑是运算符优先级。看这段简化代码i 0 while i 10 or i 5: i 1如果忽略优先级你可能以为它是“i小于10并且i大于5”的意思所以i到6就该停了。但实际上or表示“或”i 10本身对于i0到i9都是真的所以当i变成10后i 10为假但i 5为真整个条件还是真。于是陷入死循环。这个例子稍微有点刻意但实际开发中and和or混用确实容易出问题。我的建议是复杂的循环条件不要写在一行里拆成多个变量或者用函数封装可读性会好很多也更容易排查。3. 实操中常见的几种无限循环场景理论说完了这一节我想结合真实编程场景把最容易出现无限循环的几种情况整理出来。这些场景不是编的都是我自己或身边同事实际遇到过、并且在网上一搜一大把的高频问题。3.1 while循环里处理用户输入这类问题最常见于命令行交互程序。核心逻辑是程序反复询问用户输入直到用户给出合法的输入才继续。新手最容易写的错误版本是这样的user_input input(请输入一个正整数) while not user_input.isdigit(): print(输入不合法请重新输入)这个循环看起来没什么大问题但它根本不会重新读取用户的输入。user_input在循环体里没有更新所以如果用户第一次输入了非法内容程序就会在一遍一遍地打印“输入不合法”中无限循环。你按什么键都没用因为程序压根不执行input()。正确的写法是把input()放进循环体里while True: user_input input(请输入一个正整数) if user_input.isdigit(): break print(输入不合法请重新输入)我见过非常多的初学者在这里卡住。他们的直觉是“循环条件负责判断循环体负责处理和提示”但忘了用户输入这个动作本身也需要在循环体里重复执行。一旦input()不在循环内程序就失去了获取新输入的机会死循环不可避免。顺带分享一个经验写交互式脚本时尽量把“获取输入”和“检查输入是否合法”拆成独立的函数。比如def get_positive_int(): while True: raw input(请输入一个正整数) if raw.isdigit() and int(raw) 0: return int(raw) print(输入不合法请重新输入)这样主逻辑里调用num get_positive_int()看起来清爽测试也好测。3.2 遍历容器时边遍历边修改另一个高发场景是遍历列表或字典时在循环体内删除或添加元素。看这段代码numbers [1, 2, 3, 4, 5] for n in numbers: if n % 2 0: numbers.remove(n)这段代码不一定死循环但会出现“跳过元素”的诡异行为。因为for循环是按下标顺序迭代的当你删掉一个元素后后面的元素会往前移动导致某些元素从未被检查。真正容易死循环的是用while配合索引遍历numbers [1, 2, 3, 4, 5] i 0 while i len(numbers): if numbers[i] % 2 0: numbers.remove(numbers[i]) i 1这个版本看似有i 1逻辑上不会死循环但实际上它也可能漏删。为什么因为remove会让列表长度减一而i还是照常加一这就跳过了原来位置的下一个元素。虽然这里不涉及无限循环但它说明边遍历边修改容器本质上是个危险操作。那真正的死循环长什么样呢用while遍历字典时d {a: 1, b: 2} for key in d: if key a: d[c] 3这段代码在Python里会直接抛RuntimeError: dictionary changed size during iteration不算死循环。但如果你用的遍历方式不触发这个保护机制比如用while手动处理就可能陷入不断新增键值、永远遍历不完的状态。我个人遇到过的真死循环是这样的data [1, 2, 3] while data: for item in data: if item 2: data.append(4)外层while data检查列表是否为空内层for遍历的时候往data里追加元素。data永远不为空内层循环又不断添加新元素程序就无限运行下去。这类代码最常见的出现位置是程序里某个函数内部产生了新任务又把这些任务追加回同一个待处理队列里却忘了设定停止条件。处理这类问题的原则很简单如果需要在遍历过程中修改容器先收集要删除或添加的元素遍历结束后再统一处理。或者直接创建副本进行遍历for item in data[:]: if item 2: data.append(4)这里data[:]生成的是原列表的浅拷贝遍历的是副本修改的是原列表互不干扰。3.3 递归调用设计不当导致隐式死循环严格来说递归导致的无限循环不叫“死循环”而是“无限递归”。但它的破坏力比普通死循环更大因为每次递归调用都会占用栈空间最终会抛出RecursionError而不是一直空转。看这个典型例子def count_down(n): print(n) return count_down(n - 1)这个函数没有终止条件n会一直减到负数再继续减直到超出Python的递归深度限制抛出RecursionError。虽然最终程序会崩溃停止但如果你没设置sys.setrecursionlimit()来放宽限制——新手常干这事——那就可能真的把内存撑爆。正确的递归必须有基线条件base casedef count_down(n): if n 0: return print(n) count_down(n - 1)递归里的死循环还有一个变种两个函数互相调用形成“循环调用”。比如solve_a()里调用solve_b()solve_b()里又调用solve_a()如果中间没有结束条件程序就会一直互相调用直到栈溢出。我在教初学者递归的时候总是会强调一句话递归三要素——终止条件、递归调用、问题规模递减。这三个缺一个都不行。尤其是“问题规模递减”有时候你写了终止条件但调用的时候传的参数根本不是朝着终止条件方向变化的那同样会无限递归。3.4 网络请求和文件读取中的空数据处理这个场景稍微进阶一点但初学者迟早会遇到。比如用while逐行读取文件line file.readline() while line: process(line) line file.readline()这段代码有更新line逻辑上也正确。但如果你把readline()的结果存到了另一个变量而循环条件还是用原来的line判断line file.readline() while line: new_line file.readline() process(new_line)那文件名读到空字符串之前while line判断的永远是第一行的内容而文件指针已经在不断向后移动。如果文件有内容这个循环可能会一直跑直到process()内部报错或你把进程杀掉。更隐蔽的是如果文件最后一行恰好不是空行readline()会一直返回之前的某段内容死循环的可能性很大。网络请求也是重灾区。很多人写爬虫或接口轮询时会这么干while True: response requests.get(url) if response.status_code 200: break这种写法的问题在于如果接口一直返回非200状态码while True就会无限重试把服务器打到报警自己程序也卡死。正确做法是加最大重试次数max_retry 5 retry_count 0 while retry_count max_retry: response requests.get(url) if response.status_code 200: break retry_count 1这类问题的共同特征是外部环境不可控但程序内部又缺少严格的约束条件。只要外部一直不满足你的期望循环就永远不结束。4. 如何从一开始就避免写出无限循环讲完常见的产生原因这一部分我想重点说“预防”。毕竟无限循环虽然能排查但最好还是别让它发生。尤其是在生产环境里跑着的脚本一旦死循环轻则任务挂起重则拖垮服务器。4.1 给循环加上最大迭代次数保护我写脚本有个习惯任何while循环只要条件不是那种“必然会在有限步内结束”的类型我都会加一个最大迭代次数保护。这不是代码洁癖而是实实在在踩过坑之后养成的习惯。最朴素的写法是这样max_iterations 10000 count 0 while condition and count max_iterations: # 循环体 count 1 else: if count max_iterations: print(警告循环达到最大迭代次数)Python的while...else结构正好能派上用场正常循环结束会执行else分支但如果循环是因为break跳出的else分支不会执行。所以这里可以用来区分“正常结束”和“被迫结束”。如果你用的是for循环同样的逻辑可以用range上限来控制for i in range(10000): if not condition: break else: print(警告循环达到最大迭代次数)这个方法看似简单但对调试的帮助极大。程序卡死的时候如果有一条“达到最大迭代次数”的日志你能立刻知道是哪个循环出了问题而不是对着全屏日志翻半天。4.2 用for循环替代部分while循环很多场景下while循环其实可以被for循环替代。for循环天然知道什么时候结束因为它的迭代器是有穷的。举个例子你想遍历range(10)里的数字# 不太推荐的写法 i 0 while i 10: print(i) i 1 # 更推荐的写法 for i in range(10): print(i)while版本的死循环风险来自i 1这一行被遗漏或写错。而for版本根本不给你这个机会range对象会按部就班地产出数字到10就停。我知道有人会说“有些场景必须用while比如条件判断依赖外部状态根本无法预知循环次数。”这句话没错。但我的经验是即使是这种场景你往往也能拆成两步走外部状态准备好之后用for遍历范围或者用“最大次数保护 while”的组合。总之能用for就用for把“人为控制循环条件”的机会降到最低。4.3 循环内更新条件变量的规范写法如果你是初学者还在用while循环练习那请养成一个习惯在循环体开头或结尾显式更新所有参与循环条件判断的变量。比如while i 100: # 循环体 i 1 # 把变量更新放在显眼位置很多人习惯把变量更新写在循环体中间被一大段业务逻辑淹没一旦逻辑分支复杂某个分支里忘了更新变量死循环就出现了。把更新写在开头或结尾至少保证每个循环都会执行它。比这个更稳妥的做法是用“哨兵值”控制循环。比如keep_running True while keep_running: # 做事情 if some_condition: keep_running False说白了就是不要直接写while True然后靠break跳出而是用布尔变量来控制循环继续与否。这样代码的意图更明确而且你可以打日志观察keep_running的变化排查起来方便得多。当然while True break在一些场景下更简洁我也不反对。但我会建议除非这个写法让代码明显更易读否则优先用条件变量。4.4 谨慎使用裸except和空except无限循环还有一个不太起眼的帮凶不严谨的异常处理。比如while True: try: data get_data() process(data) except: pass这段代码的问题很明显不管发生什么异常都被吞掉了循环继续跑。如果get_data()永远在抛异常process(data)永远不会执行程序就会在异常和循环之间空转消耗大量CPU但表面看起来又没有崩溃。这种代码给我的感觉是最可怕的。普通死循环至少能让你看到终端一堆输出这种“异常吞噬 无限循环”的组合往往悄无声息程序看起来像正常启动但一直不出结果你甚至不知道该从哪查起。正确的做法是max_retry 3 for attempt in range(max_retry): try: data get_data() process(data) break except Exception as e: print(f第{attempt 1}次尝试失败{e})这里用了for循环限制重试次数同时保留异常信息既防止无限循环又保留了排查线索。如果你确实需要无限重试至少记录日志别让异常无声无息地消失。5. 遇到死循环时如何快速定位和终止虽然预防很重要但说实话每个人都会遇到“代码已经跑起来才发现死循环”的时刻。这一节就是讲当死循环已经发生了你该怎么办。5.1 在代码里预设调试开关和日志最好的“事后排查工具”其实是“事前埋点”。在你写循环体的时候就顺手加一行日志iteration 0 while condition: iteration 1 if iteration % 10000 0: print(f当前循环次数{iteration})这个日志有两个作用一是让你知道程序确实在跑不是卡在别的地方二是当循环次数异常增长时你能直观地看到“它停不下来”从而怀疑是循环条件的问题。如果循环体里不方便加大量日志可以加一个计数器每循环N次输出一次。这样既能追踪执行状态又不会因为输出太多把性能拖垮。在实际项目里我经常用装饰器给某些高风险函数加上“执行次数统计”一旦某个函数的调用次数在短时间内暴增日志系统就会报警。这个方法当初帮我抓出过一个隐藏得很深的死循环——那个循环嵌套在三层函数调用里光靠看代码根本发现不了。5.2 运行中强制终止的几种手段如果你已经在命令行里运行一个死循环的脚本最直接的终止方式是CtrlC。这会向Python进程发送一个KeyboardInterrupt异常。正常情况下Python会中断当前正在执行的代码并抛出异常。但如果你的代码里正好捕获了这个异常并且没做任何处理那CtrlC也会失效。比如while True: try: do_something() except KeyboardInterrupt: pass这段代码会让CtrlC失效程序“无法被正常终止”。这时候你只能强制杀进程。在Windows下可以用任务管理器找到python进程并结束在Linux/macOS下可以用kill命令kill -9 pidkill -9是强制杀信号不会给进程任何善后机会所以在生产环境慎用但本地调试时非常好使。要找到pid可以用ps aux | grep python或者更省事的是pkill -9 python但注意它会杀掉所有Python进程如果你同时跑多个脚本得小心别误杀。如果你在Jupyter Notebook或IDE里运行通常会有“中断内核”或“停止运行”的按钮。这个基本等价于发送KeyboardInterrupt但如果碰上死循环把GIL占满了界面可能也会无响应最后还是得重启内核。5.3 用调试器定位循环卡住的具体位置强制终止只是治标治本还是要找到卡住的位置。这是个很考验经验的过程我分享一个我自己常用的套路。第一步先看程序卡住时CPU占用率。如果一个Python进程CPU占用率拉满多半是在跑一个大循环如果CPU占用率很低可能是在等待外部资源比如网络请求阻塞或input()等用户输入。这个区分能帮你缩小排查范围。第二步用pdb或者IDE的断点调试。如果你的循环已经卡住你可以在IDE里点击暂停按钮程序通常会停在当前正在执行的那一行。这时候看一眼调用栈就能知道它在哪个函数、哪个循环里。第三步如果你没有IDE可以用faulthandler模块。在代码最开头加上import faulthandler faulthandler.dump_traceback_later(10, exitTrue)这个机制会在10秒后输出当前的Python调用栈然后退出程序。它对于“程序卡死在深层循环里”这种情况特别有效。有一次我用它定位了一个卡在re.match正则匹配里的死循环——那个正则表达式在一个无限循环中被反复匹配每次匹配还都成功但业务逻辑始终没有退出条件。当时要不是faulthandler直接打印了调用栈我光靠看代码可能要花一晚上。当然用调试器不是总能赶上时机。有时候死循环发生在你睡觉时、脚本已经跑了一整夜这时候日志和计数器就是唯一的线索。所以我再次强调写循环时顺手加个计数器和最大次数保护真出事的时候能省一大半排查时间。6. 高频错误速查表与避坑心得这一部分我整理了一个速查表几乎覆盖了初学者最容易踩的坑。建议你收藏起来以后写循环之前扫一眼。错误类型示例代码可能导致的结果正确的做法缺少变量更新while i 100: total i无限循环i永远不变循环体内加i 1条件恒为真while True: print(hi)无限循环无退出机制加上break或条件变量浮点数比较while x ! 1.0: x 0.1无限循环浮点精度问题用abs(x - 1.0) 1e-9或整数计数运算符优先级错误while i 10 or i 5:i从0加到10后仍然继续加括号明确逻辑while (i 10) and (i 5)循环内input但未更新循环外赋值一次循环体只负责判断无限循环程序永远在提示输入把input()放在循环体内边遍历边修改列表for n in list: list.remove(n)元素跳过或异常甚至死循环遍历副本list[:]统一修改无限递归def f(n): return f(n-1)栈溢出RecursionError添加终止条件保证规模递减吞异常 死循环while True: try: ... except: pass程序无限空转无任何提示记录异常日志设置最大重试次数外部条件长时间不满足while response.status_code ! 200:无限重试拖垮服务设置最大重试次数增加超时表里这些错误每一个我都见过不止一次。尤其是“循环内input但未更新”和“边遍历边修改列表”这两个在初学阶段出现频率极高几乎可以算“必经之坑”。我还想额外分享一个避坑技巧当你写完一个循环后试着在脑子里“人肉执行”前三轮。第一轮条件判断是不是对的第二轮变量变化后条件还是不是对的第三轮呢如果前三轮执行下来逻辑顺那至少能排除掉一大批低级错误。这个方法听起来很笨但对新手特别有效能帮你建立“循环内部状态变化”的直觉。另外写循环前先问自己一个问题“这个循环的结束条件到底是什么”如果答不上来那就别写循环先把这个条件想清楚。很多死循环的根源其实是写代码的人自己都没想明白循环什么时候该停。7. 关于循环设计我想补充的几点体会写了这么多年Python循环是我用得最频繁的控制结构之一也是我调试时间最多的结构之一。在这里我想分享几条偏“设计层面”的体会可能对你以后写代码有帮助。第一循环的退出条件最好是“正向条件”而不是“反向条件”。比如你可以写“当数据为空时退出”但不要写“当数据不为空时继续”然后寄希望于数据有一天会变空。前者更直观也更容易验证。第二尽量不要在循环体里塞太多逻辑。我见过非常多死循环问题不是循环本身写错了而是循环体太复杂某个分支路径下忘了更新状态变量。所以如果一个循环体超过20行我会考虑把内部逻辑抽成函数。这样循环体变得很薄变量更新和退出条件一眼就能看到。第三写循环的时候把“预期迭代次数”写进注释。如果你认为这个循环最多执行100次就写一行注释# 该循环最多执行 len(items) 次正常情况下应该远小于100 while items: process(items.pop())这个习惯的价值在于当后来的人包括三个月后的你自己看到这段代码时能立刻了解设计意图。如果实际运行次数远超预期也能更快意识到出了问题。第四如果是在写数据处理或数值计算的脚本循环里遇到浮点数比较一律改成整数计数或误差范围判断。这个我前面已经提过但值得重复一遍因为它太容易被忽视了。最后我想说不要因为怕死循环就不用循环。循环是编程的基本功正确的态度是理解它、掌控它而不是回避它。你只需要记住一件事那就是“循环必须有一个确定的结束路径”。把这个核心思维刻在脑子里你会发现绝大多数死循环问题都能提前避免。我自己早期学Python时为了研究死循环专门写了一堆故意制造死循环的代码然后用各种方式去终止它们、观察它们的表现。那段“折腾”的经历让我对循环机制的理解扎实了很多。所以我建议你如果时间允许也可以在自己的机器上故意写几个死循环用调试器暂停、查看调用栈、观察变量变化。亲身体验过一遍比看十篇文章都管用。在开发中你最需要明白的是无限循环不是Python的bug而是代码逻辑的盲区。保持良好的循环设计习惯配合调试工具你完全可以控制住它。希望这篇文章能帮你在Python学习的路上少踩几个坑。
返回列表