入坑C#第六课聊聊循环那些事以前在学校学循环老师讲for、while、do-while、foreach当时觉得就是写写九九乘法表、算个斐波那契没什么了不起。后来真正用C#做上位机、写数据处理程序才发现循环是整个代码逻辑的骨架——串口收发要循环、图像帧要循环、PLC点位轮询要循环就算写个批量文件处理跑不掉循环。可以说循环写得不好后面的线程、异步、集合操作都得跟着遭殃。这篇博文不打算把MSDN抄一遍而是结合我实际项目里摸爬滚打的经验把C#里各种循环的原理、选型、控制手段、性能隐患、多线程注意点还有那些让人头疼的经典坑一次说清楚。新手看完能直接上手写老手也可以顺手检查一下自己有没有踩中隐藏雷区。1. 循环的家底for、while、do-while、foreach到底怎么选1.1 for循环什么时候非它不可for循环是大家接触最多的语法形式为for(初始化; 条件; 迭代)它把循环的四个要素——初始化、条件判断、循环体、步进——紧凑地写在一行里。很多新手以为for只能用来数数其实for真正擅长的是明确知道要迭代多少次或者需要索引下标的场景。比如你要遍历一个二维数组的每个元素或者处理一张图片的像素点这时候没有索引寸步难行。更典型的是在串口通信中按字节拼接报文你收到一帧数据知道协议规定固定长度是8字节需要逐个取出并校验。byte[] frame new byte[8]; for (int i 0; i frame.Length; i) { // 模拟从缓冲队列中取字节 frame[i] buffer.Dequeue(); }这里用for而不是foreach的原因是foreach在遍历过程中拿不到索引只能拿到元素本身。虽然有些高级写法能用Select((item, index) ...)弥补但本质上还是绕了一圈没有for来得直观。另一个for的独特优势是可以在循环体内修改迭代变量比如你要隔一个取一个、反向遍历、或者按步长2跳着走for (int i array.Length - 1; i 0; i - 2) { // 从尾部每隔一个元素处理一次 }这种灵活控制是foreach做不到的。1.2 while与do-while条件驱动的两兄弟while循环的理念是只要条件满足就一直执行它擅长处理你不知道会迭代多少次、需要依赖外部状态变化的场景。最典型的例子是读取数据流直到结束、等待某个硬件状态位翻转、或者反复尝试建立通信连接。因为条件在执行前判断所以如果初始条件就不满足循环体一次都不会执行。do-while很多人用得少但它有个很实用的特性先执行一次循环体再判断条件。这意味着循环体至少会执行一次。哪些场景适合do-while比如你要先发送一条查询命令给仪表再读取返回数据如果数据无效就重试或者UI上弹出一个输入框要求用户录入有效数值如果录得不合法就重新弹。这种先做不行再说的逻辑用do-while表达最自然一般人习惯性用while再加个前置标志位反而绕。int retryCount 0; bool received false; do { serialPort.Write(queryCommand, 0, queryCommand.Length); received WaitForResponse(TimeSpan.FromMilliseconds(500)); retryCount; } while (!received retryCount 3);这是我以前写串口读取仪表数据时的真实代码片段。用do-while最舒服因为无论如何都要先发一次查询报文再根据结果判断要不要重发。1.3 foreach最安全的遍历方式foreach在C#里更像是一个语法糖底层实际上是在调用集合的GetEnumerator()来逐项访问。它最大的好处是不会越界、写法简洁、在遍历过程中集合结构发生变化时会直接抛出异常而for循环因为用索引越界了只是逻辑错误可能拿到错误数据而不自知。从性能角度看foreach在过去效率确实比for差因为存在装箱拆箱和迭代器分配的开销。但到了C# 7.3之后foreach的实现已经做了优化编译器在遍历数组时本质上会把它优化成for差不多的IL代码。再加上现在的JIT越来越聪明大部分场景下foreach和for的差距已经小到可以忽略优先用foreach来保证安全、可读性才是正路。不过要注意foreach是只读遍历——你不能在循环体内直接修改集合元素如果是ListT可以修改元素的属性但不能执行Add或Remove。后面第五节会详细说这个坑。2. 循环控制三板斧break、continue、return2.1 break跳出去、continue跳下一次break和continue是循环体内最常见的两个控制语句。break的作用是立刻终止整个循环不管条件还成不成立continue则是跳过当前这一次循环的剩余代码直接进入下一次迭代前的条件判断。理解这两个词的差别有个很生活化的比喻break就像你在排队时突然不想排了直接离开队伍continue则像你发现排到前面的人不合格让他走人自己继续等下一个。实际用起来continue特别适合做过滤比如你从一堆传感器数据里挑出温度正常的值for (int i 0; i readings.Length; i) { if (readings[i].IsInvalid) continue; ProcessReading(readings[i]); }值得注意的是有个很常见的误区是认为break一定比return好。其实在普通方法里这两种写法可达成的效果相似但break只能跳出当前这一层循环return会直接退出整个方法。如果循环外面还有收尾工作要做比如关闭文件、释放资源、记录日志return会把这些代码跳过去容易出问题。因此很多有经验的程序员倾向于用break跳出后统一收尾而不是在循环体中间直接用return。2.2 嵌套循环如何跳出外层这里有个经典的坑你在双层循环里想在内层满足某个条件后同时跳出内外两层。新手会直接在内层写break结果发现只跳出了内层外层还在继续跑数据被重复处理。C#没有类似Java的标签跳出语法常见做法是使用标志位bool exitAll false; for (int i 0; i rows !exitAll; i) { for (int j 0; j cols; j) { if (matrix[i, j] target) { result (i, j); exitAll true; break; } } }注意外层的条件里加了 !exitAll这样内层break后外层也会在下一次判断中退出。这个写法比在内层break然后外面再break要清晰得多也避免了到处写goto的混乱。2.3 死循环的几种正规写法与真实用途死循环在结构化编程里听起来是个贬义词但在实际开发中它却是很多服务端程序、上位机主循环的常规形态。典型的场景是写一个后台监听线程不断扫描队列中是否有新任务没有就sleep短暂时间有就取出来处理程序运行期间永不退出。while (true) { // 监控线程的主循环 while (messageQueue.TryDequeue(out var msg)) { ProcessMessage(msg); } Thread.Sleep(10); }也有人用for(;;)来写死循环效果完全相同。关键是死循环必须搭配明确的退出条件否则程序一挂就是灾难。我个人的习惯是尽量少用while(true)裸奔至少要在循环体里维护一个是否允许继续运行的标志位或者用CancellationToken配合异步方法这个后面第四节细讲。3. 循环嵌套与性能避坑3.1 嵌套循环的复杂度要心中有数嵌套循环最常见的负面效果就是时间复杂度急剧上升。两个嵌套就是O(n²)三个嵌套就是O(n³)数据量一大分分钟卡死。我第一次写图像处理的冒泡算法时就吃过亏一张1920x1080的灰度图双重循环遍历每个像素顺手再做点运算一帧处理下来几百毫秒视频流直接卡成幻灯片。所以嵌套循环里最先要思考的问题是能否减少内层循环的重复计算量。比如把不依赖内层变量的值提出来放在外层或循环外算好或者提前使用哈希表接查找把O(n)的查找变成O(1)。举一个最简单的例子// 低效写法每次内层循环都重新求一次Count for (int i 0; i listA.Count; i) { for (int j 0; j listB.Count; j) { // 逻辑处理 } } // 高效写法内层循环次数提前确定 int outerLen listA.Count; int innerLen listB.Count; for (int i 0; i outerLen; i) { for (int j 0; j innerLen; j) { // 逻辑处理 } }别看这只是把.Count提到外面如果内层循环要执行百万次每次少一次属性读取调用节省的时间是不可小看的。更重要的一点是如果循环体内某些数据不会变一定要提到循环外这是性能优化的黄金法则。3.2 避免循环里重复创建对象与日志输出新手最常见的效率杀手就是在循环里写string str ; str value;。字符串是不可变类型每拼接一次都会产生新对象、分配新内存数据一多GC压力直接拉满。正确做法是使用StringBuildervar sb new StringBuilder(); for (int i 0; i data.Length; i) { sb.Append(data[i]).Append(,); } string result sb.ToString();另一个容易被忽视的是在循环里输出诊断日志。比如在一个高频循环里打Console.WriteLine或者Debug.WriteLine在调试模式还行在发布模式下如果还习惯性地用Console.WriteLine它会拖慢整个程序好几个数量级。我自己写上位机时一度因为数据帧率过高循环里加了日志输出导致UI直接卡死排查了很久才发现是日志的锅。3.3 Parallel.For与LINQ循环的高阶形态当循环体之间没有依赖关系每次迭代的计算不依赖前一次结果可以考虑用Parallel.For把任务分发到多个线程并行执行充分利用多核CPU。但必须要说的是并行循环不是银弹它对共享资源的并发安全要求非常高。如果你在循环里直接操作同一个集合或者对同一个变量累加几乎必然引发线程安全问题。正确做法是在并行循环中使用局部变量或者用Interlocked、ConcurrentBag等线程安全的手段来收集结果。Parallel.For(0, 1000, i { // 每次迭代之间互不依赖 var result Calculate(i); concurrentResults.Add(result); // ConcurrentBag是线程安全集合 });如果循环只是做简单的筛选、求和、转换用LINQ往往比手写循环更简洁、可读性也更好。比如list.Where(x x.Age 18).Select(x x.Name).ToList()在数据量不大时性能完全够用。但要意识到LINQ的惰性求值特性容易在多次遍历时产生额外开销需要谨慎使用。4. 循环里的异常处理与多线程实践4.1 循环内try-catch的正确姿势有一种代码风格把整个大循环包在一个try-catch里看起来是在统一处理异常其实等于把错误吞掉了。一旦循环中某个元素引发异常后面的所有元素都处理不了因为整个循环都会跳出而且你很难判断到底失败的是哪个元素。我个人的习惯是把try-catch放进循环体内部让每次迭代独立处理异常。这样才能保证一个问题项不会影响整体批处理foreach (var file in fileList) { try { ProcessFile(file); } catch (Exception ex) { Log.Error($处理文件 {file.FullName} 失败: {ex.Message}); // 记录失败项继续处理下一个 failedFiles.Add(file); } }但也要注意一点在UI线程里的循环中如果连续弹异常对话框程序会变得没法操作。所以处理异常的时候信息要记下来最后统一汇总再提示用户或者把失败项存入一个集合循环结束后一次性反馈。这样既不会打断用户操作又不会遗漏错误信息。4.2 多线程循环与CancellationToken的配合使用后台线程执行循环时最容易遇到的问题是程序退出的时候线程还在后台循环或者用户点了停止按钮循环却不听话地继续跑。解决这个问题的标准姿势是使用CancellationToken。private void StartWorker(CancellationToken token) { Task.Run(() { while (!token.IsCancellationRequested) { // 执行循环体 CheckDeviceStatus(); Thread.Sleep(100); } }, token); }当用户想停止时调用CancellationTokenSource.Cancel()循环就会在下一次检查条件时优雅退出。这样做的好处是线程可以及时释放资源、保存当前状态而不是被强行Abort()。说句老实话Thread.Abort()是个非常危险的操作很可能导致资源未释放、状态错乱、甚至进程崩溃能不用就不用。4.3 共享变量的锁与循环在线程循环中如果循环体内要访问或修改共享变量一定要加锁或用原子操作。比如上位机里采集线程不停地更新当前温度UI线程每隔100ms读一次温度显示如果不加锁可能读到中间状态的不完整值。加锁最直接的写法是lock (obj)需要注意锁的对象必须是引用类型且所有线程都锁同一个对象private readonly object _sync new object(); private double _temperature; // 采集线程 lock (_sync) { _temperature sensor.ReadTemperature(); } // UI线程 lock (_sync) { currentTempLabel.Text _temperature.ToString(F1); }如果你追求更轻量的方案可以试试Interlocked.Exchange原子赋值不过它只适合简单的数值或引用类型变量。总的来说多线程环境下循环的每一行代码你都要在心里问一句这个变量谁在写、谁在读、会不会撞车。5. 常见问题与排查技巧实录5.1 foreach中修改集合官方死路这是C#程序员的成年礼——第一次在foreach遍历List时执行list.Remove(item)然后代码直接抛InvalidOperationException: Collection was modified。原因是foreach依赖枚举器枚举器在集合被修改后会进入失效状态这是为了防止遍历过程产生不可预期的结果而设置的保护机制。解决办法有三条路一是先收集要删除的元素等循环结束后再统一删除二是用for循环从后往前遍历删除这样可以避免索引错位三是用LINQ的ToList()创建副本再进行遍历。哪种好看场景。如果要删除的量不大我通常推荐倒序for循环for (int i list.Count - 1; i 0; i--) { if (ShouldRemove(list[i])) { list.RemoveAt(i); } }倒序删除的好处是删掉后面的元素完全不影响前面的索引这是很多初级程序员没有想明白的细节。5.2 闭包陷阱循环变量被共享另一个非常经典、非常隐蔽的坑是在for循环里创建匿名函数或Lambda表达式稍不注意就翻车。来看这个例子var actions new ListAction(); for (int i 0; i 5; i) { actions.Add(() Console.WriteLine(i)); } foreach (var action in actions) { action(); }你预期输出0、1、2、3、4实际输出是5、5、5、5、5。原因是循环变量i其实是同一个变量闭包捕获的是i的引用或者说闭包会捕获变量本身当循环结束后i的值已经变成5所有Action执行时读到的都是5。在C# 5.0之后foreach中的迭代变量每轮都是新的但for的循环变量依然只有一份。解决方法是复制一个局部变量再捕获for (int i 0; i 5; i) { int temp i; actions.Add(() Console.WriteLine(temp)); }这个问题如果你只是写简单的控制台程序可能撞不到但一旦在循环里启动Task、注册事件处理器、或者创建委托回调它就变成了极其隐蔽的随机Bug排查起来很痛苦。5.3 死循环排查与调试技巧程序卡死、CPU飙到100%最常见的元凶就是死循环。排查手段我建议按这个顺序来先看任务管理器里CPU占用如果某进程飙满大概率有死循环或忙等再进Visual Studio按“全部中断”查看调用堆栈找到当前执行位置。几乎百分之百能定位到出问题的循环。如果不想实时调试也可以在关键循环里加日志但要控制频率比如每1000次迭代输出一条不然日志刷屏反而影响性能。或者使用条件断点——右击断点设置条件为i 99999让断点只在接近理论最大值时才命中这样可以快速判断循环是否配合预想步进走到了终点。还有一个经验如果循环里的Thread.Sleep被注释掉了一些等待型循环会变成忙等白白消耗CPU。例如轮询串口数据时如果忘记Sleep程序会瞬间吃掉一个核心的全部算力。所以写循环一定要记得给“喘息机会”。5.4 采集图像、摄像头设备时循环遍历的注意点在热搜词里看到c# directshow uvc 回调里区分多个摄像头这其实也和循环紧密相关——如果你用DirectShow枚举摄像头设备会得到一长串设备列表需要循环遍历去匹配DevicePath或者FriendlyName来找到你真正想用的那一路画面。很多人在这个遍历循环里直接用Contains去匹配名字结果设备名字带了个细微空格差异就匹配不上。我的建议是在做设备匹配这种细节工作时做好字符串规范化——去掉首尾空格、统一大小写再比较。如果要区分多个摄像头尽量用设备唯一识别码如DevicePath而不是显示名称。循环遍历时也最好加上遍历集合的最大次数保护防止设备异常导致无限循环。这些看起来都是小事但一旦现场出现多摄像头错位排查起来真的让人头皮发麻。写在最后的经验循环是C#最基础的东西但越是基础的东西用好的上限越高。我自己的体会是写循环前先花几秒钟想清楚三件事这个循环到底要遍历什么、访问方式是什么、有没有并发访问的风险。C#提供的for、while、do-while、foreach各有用武之地没有谁能完全替代谁。奉劝刚入门的同学不要以为学会了for就天下无敌多看看Parallel.For、LINQ、线程安全集合这些循环的衍生形态在项目里遇到它们时才有底气。最后再分享一个小习惯每次写完一段循环我会刻意检查一下循环体里有没有可以提到外层的代码、有没有重复创建的对象、有没有可能被修改的共享变量。这个习惯帮我挡掉了无数运行时的麻烦。循环写稳了后面学集合、学LINQ、学异步都会顺畅很多。