You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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;
        }
    }
}

使用说明

  1. 当其他代码需要调用Console.WriteLine或写入多行内容时,调用ProgressSpinner.SyncLineCount(n)同步行计数器,其中n为写入的行数。例如:
Console.WriteLine("操作执行完成");
ProgressSpinner.SyncLineCount(1);
  1. 该方案通过Console.WindowTop获取当前可见区域的起始绝对行号,将维护的绝对行号转换为终端的相对行号,确保进度指示器定位到正确的物理行。

关键原理

  • 自行维护的_absoluteLineCounter不受终端行为影响,始终准确追踪程序启动以来的总行数
  • 通过Console.WindowTop实现绝对行号与终端相对行号的转换,适配不同终端的坐标系统
  • 全局锁确保多线程下的行号追踪和光标操作线程安全

内容的提问来源于stack exchange,提问作者fs64

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 22:25:56