C#控制台多进度指示器滚动重叠问题及通用解决方案求助
多任务控制台进度指示器滚动重叠问题的解决方案
我在C#控制台应用中实现了可在同一行更新的多任务进度指示器,但遇到了一个bug:当终端视图滚动到底部开始滚动时,所有进度项会重叠在最后一行更新。该问题仅在标准Windows命令提示符(CMD)中不会出现,在Windows Terminal、Git Bash、Alacritty等终端均会发生。
复现代码
主程序代码
internal class Program { static async Task Main(string[] args) { Log($"New Start at line {Console.CursorTop}"); var tasks = new List<Task>(); for (int i = 0; i < 5; i++) { tasks.Add(DownloadFiles(i)); } await Task.WhenAll(tasks); } static async Task DownloadFiles(int itemNumber) { const int max = 20; var spinner = new ProgressSpinner(); for (int j = 1; j <= max; j++) { spinner.Refresh($"Item {itemNumber}, {j}/{max} files."); await Task.Delay(50); } } }
ProgressSpinner原始实现
class ProgressSpinner { private static readonly object locker = new(); private static readonly char[] spinnerChars = ['|', '/', '-', '\\']; private int spinnerIndex = 0; private readonly int line; public ProgressSpinner() { lock (locker) { Console.WriteLine(); line = Console.CursorTop; Program.Log(line); } } public void Refresh(string message) { lock (locker) { int prevPos = Console.CursorTop; Console.SetCursorPosition(0, line); Console.Write($"{spinnerChars[spinnerIndex]} {message}"); spinnerIndex = (spinnerIndex + 1) % spinnerChars.Length; Console.SetCursorPosition(0, prevPos); } } }
问题根源
通过日志分析发现,不同终端对Console.CursorTop的定义存在差异:
- 在CMD中,
Console.CursorTop代表从程序启动首行开始计算的绝对行号 - 在Windows Terminal等终端中,
Console.CursorTop代表当前可见区域的相对行号(以首行可见行为0)
日志输出验证了这一点,所有ProgressSpinner实例都获取到了相同的行号,导致更新时全部定位到同一行:
New Start at line 54 54 54 54 54 54
通用解决方案
核心思路是自行维护全局的绝对行计数器,替代依赖终端行为的Console.CursorTop,同时支持与其他控制台写入操作的行号同步。
改进后的ProgressSpinner实现
class ProgressSpinner { private static readonly object locker = new(); private static readonly char[] spinnerChars = ['|', '/', '-', '\\']; // 全局追踪程序启动以来写入的绝对总行数 private static int _absoluteLineCounter; private int spinnerIndex = 0; private readonly int _absoluteLine; public ProgressSpinner() { lock (locker) { // 先记录当前进度项的绝对行号,再写入空行并更新计数器 _absoluteLine = _absoluteLineCounter; Console.WriteLine(); _absoluteLineCounter++; Program.Log(_absoluteLine); } } public void Refresh(string message) { lock (locker) { int prevRelativeTop = Console.CursorTop; // 将绝对行号转换为终端的相对行号:绝对行号 - 可见区域起始绝对行号 int targetRelativeLine = _absoluteLine - Console.WindowTop; // 确保目标行在当前可见区域内 if (targetRelativeLine >= 0 && targetRelativeLine < Console.WindowHeight) { Console.SetCursorPosition(0, targetRelativeLine); Console.Write($"{spinnerChars[spinnerIndex]} {message}"); spinnerIndex = (spinnerIndex + 1) % spinnerChars.Length; } // 恢复光标到之前的位置 Console.SetCursorPosition(0, prevRelativeTop); } } // 用于同步其他代码写入控制台的行号 public static void SyncLineCount(int linesWritten) { lock (locker) { _absoluteLineCounter += linesWritten; } } }
使用说明
- 当其他代码需要调用
Console.WriteLine或写入多行内容时,调用ProgressSpinner.SyncLineCount(n)同步行计数器,其中n为写入的行数。例如:
Console.WriteLine("操作执行完成"); ProgressSpinner.SyncLineCount(1);
- 该方案通过
Console.WindowTop获取当前可见区域的起始绝对行号,将维护的绝对行号转换为终端的相对行号,确保进度指示器定位到正确的物理行。
关键原理
- 自行维护的
_absoluteLineCounter不受终端行为影响,始终准确追踪程序启动以来的总行数 - 通过
Console.WindowTop实现绝对行号与终端相对行号的转换,适配不同终端的坐标系统 - 全局锁确保多线程下的行号追踪和光标操作线程安全
内容的提问来源于stack exchange,提问作者fs64
相关产品推荐
相关产品推荐

