02-并发的底层-Thread-ThreadPool-Task

02-并发的底层:Thread、ThreadPool 与 Task 的关系

本章 GitHub 仓库csharp-concurrency-cookbook

欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。


写在前面的话

各位好!

先给大家打个预防针:这篇博客的内容真的很多,而且绝大部分都是偏理论的深度解析。我不会跟你讲”怎么快速用Task”,而是会带你深入到操作系统层面,搞清楚Thread、ThreadPool和Task的本质区别底层机制

预计阅读时间:30-60分钟(取决于你的基础和阅读速度)

内容密度:(满分5颗钻,这篇给5颗)

建议

  • 泡杯咖啡或茶,找个安静的地方
  • 不要快速浏览,很多细节值得反复琢磨
  • 对照着代码示例理解,效果最好
  • 🤔 看不懂的地方可以先跳过,回头再看

为什么值得你花这个时间?

因为这些知识是真正的硬核干货,搞懂了之后:

  • 你会明白为什么”10000个Thread会崩溃,10000个Task却轻松运行”
  • 你会理解.NET Core线程池相比.NET Framework快4-10倍的原因
  • 你会知道async/await为什么能用几十个线程处理上万个并发请求
  • 你会掌握性能优化的底层原理,而不是”听说要用Task”

这篇文章讲述的就是并发编程的底层原理,是后续所有章节的理论基础,是你成为并发编程高手的必经之路。

所以,如果你真的想在并发编程上有所突破,请沉下心,认真看完这篇。保证你不会白花时间!

好了,废话不多说,让我们开始吧!


核心问题:从 Thread 到 Task,.NET 并发编程经历了怎么演化?它们之间是什么关系?


引言:一个让人震惊的实验

在第一章的结尾,我们留了一个实验:分别创建 10000 个线程和 10000 个 Task,观察它们的差异

现在让我们来做这个实验,结果可能会让你大吃一惊。

实验 1:创建 10000 个线程

// 代码示例:ThreadVsTaskDemo.cs
private static void CreateTenThousandThreads()
{
    const int threadCount = 10000;
    var sw = Stopwatch.StartNew();
    var threads = new Thread[threadCount];

    Console.WriteLine($"开始创建 {threadCount} 个线程...");

    for (int i = 0; i < threadCount; i++)
    {
        threads[i] = new Thread(() =>
        {
            Thread.Sleep(5000);  // 模拟工作 5 秒
        });
        threads[i].Start();

        if ((i + 1) % 1000 == 0)
        {
            Console.WriteLine($"  已创建 {i + 1} 个线程...");
        }
    }

    Console.WriteLine("所有线程已启动,等待完成...");

    foreach (var thread in threads)
    {
        thread.Join();
    }

    sw.Stop();

    Console.WriteLine($" 完成时间:{sw.ElapsedMilliseconds}ms");
    Console.WriteLine($" 创建的线程数:{threadCount} 个");
    Console.WriteLine($" 虚拟内存预留:约 {threadCount / 1024.0:F2} GB(每线程 1MB 栈空间)");
    Console.WriteLine($" 实际物理内存:请查看任务管理器(约 1-2 GB,因为栈未被充分使用)");
    Console.WriteLine();
    Console.WriteLine("说明:");
    Console.WriteLine("  - Windows 为每个线程预留 1MB 虚拟地址空间");
    Console.WriteLine("  - 但只有栈被实际访问时,才分配物理内存(4KB 页)");
    Console.WriteLine("  - 当前代码仅调用 Thread.Sleep(),栈使用很少");
    Console.WriteLine("  - 若要查看虚拟内存占用,请使用 Process Explorer 工具");
}

运行结果

开始创建 10000 个线程...
  已创建 1000 个线程...
  已创建 2000 个线程...
  ...
  已创建 10000 个线程...
所有线程已启动,等待完成...
 完成时间:约 5000-10000ms(取决于系统)
 创建的线程数:10000 个
 虚拟内存预留:约 9.77 GB(每线程 1MB 栈空间)
 实际物理内存:约 1-2 GB(任务管理器显示)

说明:
  - Windows 为每个线程预留 1MB 虚拟地址空间
  - 但只有栈被实际访问时,才分配物理内存(4KB 页)
  - 当前代码仅调用 Thread.Sleep(),栈使用很少
  - 若要查看虚拟内存占用,请使用 Process Explorer 工具

关于内存占用的重要说明

你可能会注意到:任务管理器显示的内存只有 1-2GB,而不是理论的 10GB。这是正常现象!

为什么?

  • 虚拟内存(预留):10GB —— 这是操作系统为所有线程栈预留的虚拟地址空间
  • 物理内存(实际):1-2GB —— 这是实际分配的物理内存

关键机制:按需分配(Demand Paging)

  • Windows 为每个线程预留 1MB 虚拟地址空间(VirtualAlloc)
  • 但只有当栈空间被实际访问时,才分配物理内存(4KB 页为单位)
  • 本实验的线程只调用 Thread.Sleep(),几乎不使用栈空间
  • 没有局部变量、没有深度函数调用 → 栈使用极少 → 物理内存分配极少

真实场景对比:

简单场景(本实验):
  - 虚拟:10GB
  - 物理:1-2GB
  - 原因:栈使用极少

复杂场景(实际应用):
  - 虚拟:10GB
  - 物理:5-10GB
  - 原因:深度调用栈、大量局部变量

本实验的真正重点:

  • 线程数量过多:10000 个 OS 线程的创建和管理成本
  • 上下文切换频繁:大量线程导致 CPU 浪费在调度上
  • 内存不是核心问题:虽然虚拟内存很大,但物理内存消耗取决于实际使用

要观察虚拟内存?使用 Process Explorer:

其他性能问题:

  • CPU 上下文切换:频繁
  • 系统响应:卡顿/崩溃
  • 线程创建时间:50-200 微秒/线程

实验 2:创建 10000 个 Task

// 代码示例:ThreadVsTaskDemo.cs
private static async Task CreateTenThousandTasksAsync()
{
    const int taskCount = 10000;
    var sw = Stopwatch.StartNew();
    var tasks = new Task[taskCount];

    Console.WriteLine($"开始创建 {taskCount} 个 Task...");

    // 记录初始线程数
    var initialThreadCount = ThreadPool.ThreadCount;

    for (int i = 0; i < taskCount; i++)
    {
        tasks[i] = Task.Run(async () =>
        {
            await Task.Delay(5000);  // I/O 密集型:不占用线程
        });
    }

    // 等待一下让任务启动
    await Task.Delay(100);

    // 查看运行时的线程数
    var runningThreadCount = ThreadPool.ThreadCount;

    Console.WriteLine("所有 Task 已启动,等待完成...");

    await Task.WhenAll(tasks);

    sw.Stop();

    Console.WriteLine($" 完成时间:{sw.ElapsedMilliseconds}ms");
    Console.WriteLine($" 实际使用线程数:约 {runningThreadCount}(初始:{initialThreadCount})");
    Console.WriteLine($" Task 对象内存:约 {taskCount * 200 / 1024.0 / 1024.0:F2} MB");
    Console.WriteLine($" 性能优势:线程数减少约 {taskCount / Math.Max(runningThreadCount, 1)}x");

    // 对比说明
    Console.WriteLine($"\n对比分析:");
    Console.WriteLine($"  - 如果用 {taskCount} 个 Thread:内存约 {taskCount / 1024.0:F2} GB");
    Console.WriteLine($"  - 实际用 Task:内存约 {taskCount * 200 / 1024.0 / 1024.0:F2} MB");
    Console.WriteLine($"  - 内存节省:约 {(taskCount / 1024.0 * 1024) / (taskCount * 200 / 1024.0):F0}x");
}

运行结果

开始创建 10000 个 Task...
所有 Task 已启动,等待完成...
 完成时间:约 5000ms
 实际使用线程数:约 10-20(初始:4-8)
 Task 对象内存:约 1.91 MB
 性能优势:线程数减少约 500-1000x

对比分析:
  - 如果用 10000 个 Thread:内存约 9.77 GB(虚拟内存)
  - 实际用 Task:内存约 1.91 MB(Task 对象)
  - 内存节省:约 5000x

🤔 为什么差距如此巨大?

这个实验揭示了一个核心问题:

Thread:
  - 每个 Thread = 1 个 OS 线程
  - 10000 个 Thread = 10000 个 OS 线程
  - 虚拟内存开销:约 10GB(预留)
  - 物理内存开销:1-10GB(取决于实际使用)
  - 上下文切换:频繁(10000 个线程竞争 CPU)
  - 创建/销毁:昂贵(每个线程 50-200 微秒)

Task:
  - Task ≠ 线程
  - Task = 异步操作的抽象
  - 10000 个 Task 可能只用 10-20 个线程
  - 内存开销:约 2 MB(Task 对象)
  - 上下文切换:少(只有 10-20 个线程)
  - 复用线程池:无创建/销毁成本

引发思考

1. Thread 和 Task 到底是什么关系?
2. ThreadPool 是如何优化线程使用的?
3. Task 是如何实现"用少量线程处理大量任务"的?
4. 为什么实际物理内存远小于虚拟内存?(按需分配机制)
  - 上下文切换:少
  - 复用线程池

带着这些问题,我们开始深入探索 .NET 并发编程的底层机制。


️ Part 1: Thread 的成本与代价

1.1 Thread 的本质

Thread(线程) 是操作系统(OS)级别的执行单元。

线程的底层实现

当你写下 var thread = new Thread(() => { /* work */ }); 时,系统底层究竟发生了什么?

完整流程图

flowchart TD A[用户代码: var thread = new Thread] –> B[C# 编译器] B –> C[生成 IL 代码] C –> D[运行时: CLR 接管] D –> E[创建 Thread 对象<br/>托管内存分配] E –> F{调用 thread.Start?} F –>|否| G[Thread 对象创建完成<br/>状态: Unstarted] F –>|是| H[CLR 调用 ThreadNative::Start] H –> I[P/Invoke: 调用操作系统 API] I –> J{操作系统类型?} J –>|Windows| K[调用 CreateThread API] J –>|Linux| L[调用 pthread_create] K –> M[Windows 内核处理] L –> M M –> N[分配内核线程对象<br/>KTHREAD 结构] N –> O[分配用户栈空间<br/>默认 1 MB] O –> P[创建 TEB<br/>Thread Environment Block] P –> Q[初始化线程上下文<br/>寄存器、栈指针等] Q –> R[线程加入调度器队列] R –> S[返回线程句柄给 CLR] S –> T[CLR 保存线程句柄] T –> U[线程状态: Running<br/>等待 OS 调度] U –> V[OS 调度器分配 CPU 时间片] V –> W[线程开始执行用户代码] style A fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style W fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style M fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style E fill:#e1bee7,color:#6a1b9a,stroke:#7b1fa2,stroke-width:2px style O fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px

详细步骤解析

第一阶段:托管层(CLR)
// 用户代码
var thread = new Thread(() => 
{
    Console.WriteLine("Hello from thread!");
});

// 对应的底层操作:
// 1. CLR 分配 Thread 对象(托管堆)
// 2. 初始化 Thread 对象的字段:
//    - m_Delegate(要执行的委托)
//    - m_ThreadStart(线程入口点)
//    - m_ThreadId(初始为 0)
//    - m_ThreadState(Unstarted)
第二阶段:启动线程
thread.Start();  // ← 这一步才真正创建 OS 线程

// 底层流程:
// CLR 内部调用栈:
Thread.Start()
  └─ StartInternal()
      └─ ThreadNative::Start()  // C++ 实现
          └─ P/Invoke
              └─ CreateThread (Windows)
              └─ pthread_create (Linux)
第三阶段:操作系统层

Windows 系统

// 伪代码:操作系统内核做了什么

HANDLE CreateThread(
    LPSECURITY_ATTRIBUTES lpThreadAttributes,
    SIZE_T dwStackSize,              // 栈大小,默认 1 MB
    LPTHREAD_START_ROUTINE lpStartAddress,
    LPVOID lpParameter,
    DWORD dwCreationFlags,
    LPDWORD lpThreadId
)
{
    // 1. 分配内核线程对象(KTHREAD)
    PKTHREAD kthread = AllocateKernelThreadObject();

    // 2. 分配用户态栈空间(默认 1 MB)
    PVOID stackBase = VirtualAlloc(NULL, 1 * 1024 * 1024, ...);

    // 3. 创建线程环境块(TEB)
    PTEB teb = CreateThreadEnvironmentBlock();
    teb->StackBase = stackBase;
    teb->StackLimit = stackBase + stackSize;

    // 4. 初始化线程上下文(寄存器、栈指针等)
    CONTEXT context;
    InitializeContext(&context, lpStartAddress, stackBase);

    // 5. 将线程加入调度器的就绪队列
    KeReadyThread(kthread);

    // 6. 返回线程句柄
    return CreateHandle(kthread);
}

Linux 系统

// 伪代码:pthread_create 的内部实现

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg)
{
    // 1. 分配线程描述符(task_struct)
    struct task_struct *new_task = alloc_task_struct();

    // 2. 分配栈空间(默认 8 MB,可配置)
    void *stack = mmap(NULL, PTHREAD_STACK_MIN, ...);

    // 3. 复制父线程的资源(文件描述符、信号等)
    copy_process(new_task, CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND);

    // 4. 设置线程入口点和参数
    new_task->thread.sp = (unsigned long)stack + PTHREAD_STACK_MIN;
    new_task->thread.ip = (unsigned long)start_routine;

    // 5. 唤醒线程(加入调度队列)
    wake_up_new_task(new_task);

    return 0;
}
第四阶段:内存分配详情

每个线程的内存占用

flowchart TB subgraph Thread[“线程内存布局 (总计:1-8MB)”] direction TB Stack[“用户态栈 Stack<br/>━━━━━━━━━━━━━━━━<br/>大小: 1 MB Windows 默认<br/> 8 MB Linux 默认<br/>━━━━━━━━━━━━━━━━<br/>用途: 存储局部变量和函数调用栈”] TEB[“TEB / TLS<br/>Thread Environment Block<br/>━━━━━━━━━━━━━━━━<br/>大小: 约 4 KB<br/>━━━━━━━━━━━━━━━━<br/>用途: 线程局部存储”] Kernel[“内核线程对象<br/>KTHREAD / task_struct<br/>━━━━━━━━━━━━━━━━<br/>大小: 约几 KB<br/>位置: 内核空间<br/>━━━━━━━━━━━━━━━━<br/>用途: 线程元数据、调度信息”] Stack –> TEB TEB –> Kernel end style Stack fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style TEB fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style Kernel fill:#e1bee7,color:#6a1b9a,stroke:#7b1fa2,stroke-width:2px style Thread fill:#e0e0e0,color:#424242,stroke:#616161,stroke-width:2px

第五阶段:线程调度
时间轴:
0 ms:  thread.Start() 调用
  ↓
5 ms:  OS 创建线程对象完成
  ↓
10 ms: 线程进入就绪队列
  ↓
???:   等待 CPU 时间片(取决于系统负载)
  ↓
15 ms: OS 调度器分配 CPU
  ↓
15 ms: 线程开始执行用户代码

关键点

  • new Thread() 只是创建托管对象,不消耗 OS 资源
  • thread.Start() 才真正创建 OS 线程,消耗 1-8 MB 内存
  • ️ 线程创建后不会立即执行,需要等待 OS 调度

Thread 的内存开销

每个线程的内存占用(虚拟 vs 物理)

组成部分 虚拟内存(预留) 物理内存(实际使用) 说明
栈空间 Windows: 1 MB
Linux: 8 MB
取决于实际使用
通常 4-16 KB(简单代码)
最多接近虚拟大小(深度递归)
用于存储局部变量和调用栈
按需分配(Demand Paging)
TEB / TLS 几 KB 几 KB 线程环境块(Thread Environment Block)
内核对象 几 KB 几 KB 内核中的线程结构
总计 约 1-8 MB 通常 8-32 KB
(取决于代码复杂度)
虚拟内存是预留,物理内存是实际分配

关键概念:虚拟内存 vs 物理内存

虚拟内存(Virtual Memory):
  - 操作系统为线程栈预留的地址空间
  - Windows:VirtualAlloc() 预留 1 MB
  - Linux:mmap() 预留 8 MB
  - 不占用物理 RAM,仅占用地址空间

物理内存(Physical Memory / RAM):
  - 实际分配的 RAM 页(4KB 为单位)
  - 只有当栈被访问时才分配(Demand Paging)
  - 简单代码(如 Thread.Sleep)仅使用几 KB
  - 复杂代码(深度递归、大量局部变量)可能使用接近 1 MB

计算示例(虚拟内存预留)

Windows(每个线程预留 1 MB 栈空间):
  1000 个线程 = 1000 MB ≈ 1 GB(虚拟内存)
  10000 个线程 = 10000 MB ≈ 10 GB(虚拟内存)

  但物理内存:
  - 简单代码:1000 个线程 ≈ 8-16 MB
  - 复杂代码:1000 个线程 ≈ 500 MB - 1 GB

Linux(每个线程预留 8 MB 栈空间):
  1000 个线程 = 8000 MB ≈ 8 GB(虚拟内存)
  10000 个线程 = 80000 MB ≈ 78 GB(虚拟内存)

  但物理内存类似 Windows(取决于实际使用)

为什么会有这个差异?

操作系统使用按需分配(Demand Paging)策略:

  1. 创建线程时,预留虚拟地址空间(VirtualAlloc/mmap)
  2. 栈空间被访问时,才分配物理内存(4KB 页)
  3. 本章开头实验中的 Thread.Sleep() 几乎不使用栈 → 物理内存很少
  4. 实际应用中的深度调用栈会使用更多物理内存

观察方法

  • 任务管理器:显示工作集(Working Set) = 物理内存
  • Process Explorer:可查看虚拟大小(Virtual Size) = 虚拟内存

1.2 Thread 的上下文切换成本

什么是上下文切换?

上下文切换(Context Switch) 是指 CPU 从一个线程切换到另一个线程的过程。这是多线程并发的基础,但也是性能开销的主要来源。

简化流程

线程 A 正在运行
    ↓
时间片用完(或 I/O 阻塞)
    ↓
保存 A 的状态(寄存器、栈指针、PC)
    ↓
加载线程 B 的状态
    ↓
线程 B 开始运行

但实际的底层流程远比这复杂得多。


上下文切换的完整流程

sequenceDiagram participant A as 线程 A participant CPU as CPU participant OS as OS 内核 participant Mem as 内存 participant B as 线程 B Note over A,CPU: 线程 A 正在执行 A->>CPU: 执行指令 CPU->>CPU: 时间片用完 / I/O 阻塞 Note over CPU,OS: 第一阶段:保存上下文 CPU->>OS: 触发上下文切换 OS->>OS: 进入内核态 OS->>Mem: 保存线程 A 的寄存器<br/>(PC, SP, 通用寄存器) OS->>Mem: 保存线程 A 的栈指针 OS->>Mem: 保存线程 A 的 CPU 状态 Note over OS,Mem: 第二阶段:选择下一个线程 OS->>OS: 运行调度算法<br/>(选择线程 B) OS->>Mem: 加载线程 B 的 PCB<br/>(进程控制块) Note over OS,CPU: 第三阶段:加载新上下文 OS->>Mem: 从内存读取线程 B 的寄存器 OS->>CPU: 恢复线程 B 的 PC OS->>CPU: 恢复线程 B 的 SP OS->>CPU: 恢复线程 B 的通用寄存器 OS->>CPU: 切换到用户态 Note over CPU,B: 线程 B 开始执行 CPU->>B: 执行指令


为什么上下文切换如此昂贵?

1. 直接时间成本

需要保存和恢复的内容

组件 内容 数量
程序计数器(PC) 下一条指令的地址 1 个
栈指针(SP) 当前栈的位置 1 个
通用寄存器 计算数据 16-32 个(x64)
浮点寄存器 浮点运算数据 16 个(x64)
SIMD 寄存器 向量运算数据 16 个(AVX)
段寄存器 内存分段信息 6 个
标志寄存器 CPU 状态标志 1 个
TLB 页表缓存 数百条

时间分解

保存寄存器状态:      约 0.5-1 微秒
运行调度算法:        约 0.5-2 微秒
加载新线程状态:      约 0.5-1 微秒
刷新 TLB:           约 0.5-1 微秒
进入/退出内核态:     约 0.5-1 微秒
────────────────────────────────────
总计:                约 2-7 微秒

对比:一个简单的 CPU 指令执行时间约 1 纳秒,上下文切换相当于执行 2000-7000 条 CPU 指令!


2. CPU 缓存失效(Cache Miss)

这是上下文切换最大的隐性成本。

CPU 缓存层次结构

flowchart TB subgraph Core[“CPU 核心”] direction TB L1[“L1 缓存<br/>━━━━━━━━━━━━<br/>大小: 32-64 KB<br/>延迟: 约 4 个时钟周期<br/>数据 + 指令”] L2[“L2 缓存<br/>━━━━━━━━━━━━<br/>大小: 256 KB – 1 MB<br/>延迟: 约 12 个时钟周期<br/>私有缓存”] L1 –> L2 end L3[“L3 缓存<br/>━━━━━━━━━━━━<br/>大小: 8-32 MB<br/>延迟: 约 40 个时钟周期<br/>共享缓存”] RAM[“主内存 RAM<br/>━━━━━━━━━━━━<br/>大小: 8-128 GB<br/>延迟: 约 200 个时钟周期<br/>DRAM”] Core –> L3 L3 –> RAM style Core fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style L1 fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style L2 fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style L3 fill:#ffe0b2,color:#e65100,stroke:#ef6c00,stroke-width:2px style RAM fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px

上下文切换导致的缓存失效

flowchart TD A[线程 A 执行] –> B[L1/L2/L3 缓存<br/>存储线程 A 的数据] B –> C[时间片用完<br/>上下文切换] C –> D[切换到线程 B] D –> E[线程 B 访问数据] E –> F{数据在缓存中?} F –>|否| G[Cache Miss!<br/>从内存加载] F –>|是| H[Cache Hit<br/>快速访问] G –> I[延迟:约 200 个时钟周期] H –> J[延迟:约 4 个时钟周期] style G fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style I fill:#ef9a9a,color:#c62828,stroke:#d32f2f,stroke-width:2px style H fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style J fill:#a5d6a7,color:#1b5e20,stroke:#2e7d32,stroke-width:2px

性能影响

假设:
- CPU 频率:3 GHz(每时钟周期约 0.33 纳秒)
- L1 缓存命中率(无上下文切换):95%
- L1 缓存命中率(频繁上下文切换):50%

无上下文切换:
  95% × 4 周期 + 5% × 200 周期 = 13.8 周期 ≈ 4.6 纳秒

频繁上下文切换:
  50% × 4 周期 + 50% × 200 周期 = 102 周期 ≈ 34 纳秒

性能下降:34 / 4.6 ≈ 7.4 倍!

3. TLB(Translation Lookaside Buffer)失效

TLB 的作用

  • 缓存虚拟地址到物理地址的映射
  • 避免每次内存访问都查页表

上下文切换的影响

线程 A → 线程 B
    ↓
TLB 中存储的是线程 A 的地址映射
    ↓
线程 B 需要不同的地址映射
    ↓
TLB 失效(Flush)
    ↓
线程 B 的每次内存访问都需要查页表
    ↓
延迟增加:从 1 个时钟周期 → 10-100 个时钟周期

4. 指令流水线停顿

CPU 流水线

指令 1: 取指 → 解码 → 执行 → 访存 → 写回
指令 2:       取指 → 解码 → 执行 → 访存 → 写回
指令 3:             取指 → 解码 → 执行 → 访存 → 写回

上下文切换导致流水线清空

线程 A 执行中:
  流水线:[指令5][指令4][指令3][指令2][指令1]
    ↓
上下文切换
    ↓
流水线清空:[   ][   ][   ][   ][   ]
    ↓
线程 B 开始:
  重新填充流水线:[指令1][   ][   ][   ][   ]
    ↓
浪费:约 10-20 个时钟周期

上下文切换的总成本

完整成本分解

成本类型 直接成本 间接成本 总计
寄存器保存/恢复 约 2 微秒 2 微秒
L1 缓存失效 约 10-50 微秒 10-50 微秒
L2 缓存失效 约 20-100 微秒 20-100 微秒
TLB 失效 约 5-20 微秒 5-20 微秒
流水线停顿 约 0.01-0.1 微秒 0.01-0.1 微秒
调度算法 约 1 微秒 1 微秒
总成本 约 3 微秒 约 35-170 微秒 38-173 微秒

关键洞察:直接成本只占总成本的约 5-10%,间接成本(缓存失效)才是主要开销


实验:测量上下文切换成本

// 代码示例:ContextSwitchCostDemo.cs
static void MeasureContextSwitchCost()
{
    const int iterations = 1_000_000;
    
    // 单线程基准测试
    var sw1 = Stopwatch.StartNew();
    for (int i = 0; i < iterations; i++)
    {
        // 简单计算
        var result = i * i;
    }
    sw1.Stop();
    Console.WriteLine($"单线程:{sw1.ElapsedMilliseconds}ms");
    
    // 多线程(强制上下文切换)
    var sw2 = Stopwatch.StartNew();
    var threads = new Thread[10];
    for (int i = 0; i < 10; i++)
    {
        threads[i] = new Thread(() =>
        {
            for (int j = 0; j < iterations / 10; j++)
            {
                var result = j * j;
                Thread.Sleep(0);  // 强制上下文切换
            }
        });
        threads[i].Start();
    }
    foreach (var t in threads) t.Join();
    sw2.Stop();
    Console.WriteLine($"多线程(频繁切换):{sw2.ElapsedMilliseconds}ms");
}

预期结果

单线程:约 5ms
多线程(频繁切换):约 500ms(慢 100 倍!)

结论

  • ️ 上下文切换的直接成本:2-7 微秒
  • ️ 上下文切换的间接成本:35-170 微秒(缓存失效)
  • ️ 总成本:相当于 38000-173000 条 CPU 指令

False Sharing(伪共享):多线程的隐藏杀手

False Sharing 是多线程编程中最容易被忽视的性能杀手之一。它发生在多个线程访问同一 缓存行(Cache Line) 的不同数据时。

什么是缓存行?

CPU 缓存不是按单个字节读取的,而是按 缓存行(Cache Line) 为单位,通常是 64 字节(x86/x64 架构的典型值,ARM 架构可能为 32、64 或 128 字节)。

缓存行结构(x86/x64 典型值:64 字节):
┌────────────────────────────────────────────────────┐
│ 字节 0-7 │ 字节 8-15 │ ... │ 字节 56-63 │
│  数据 A  │   数据 B  │ ... │   数据 H   │
└────────────────────────────────────────────────────┘
False Sharing 的原理

sequenceDiagram participant T1 as 线程 1<br/>(CPU 核心 1) participant T2 as 线程 2<br/>(CPU 核心 2) participant Cache1 as L1 缓存 1 participant Cache2 as L2 缓存 2 participant Mem as 主内存 Note over T1,Mem: 初始状态:缓存行共享 T1->>Cache1: 读取数据 A<br/>(缓存行的前 8 字节) Cache1->>Mem: 加载整个缓存行<br/>(64 字节) T2->>Cache2: 读取数据 B<br/>(缓存行的后 8 字节) Cache2->>Mem: 加载整个缓存行<br/>(64 字节) Note over T1,Cache2: 两个缓存都有相同的缓存行 Note over T1,Mem: 线程 1 修改数据 A T1->>Cache1: 写入数据 A Cache1->>Cache1: 标记缓存行为 Modified Note over Cache1,Cache2: 缓存一致性协议触发 Cache1–>>Cache2: 使 Cache2 中的缓存行失效! Note over T2,Mem: 线程 2 读取数据 B(不同的数据!) T2->>Cache2: 读取数据 B Cache2->>Cache2: Cache Miss!(缓存行失效) Cache2->>Mem: 重新加载整个缓存行<br/>延迟 ~200 个时钟周期 Note over T1,T2: 尽管修改的是不同数据<br/>但因为在同一缓存行<br/>导致另一个线程的缓存失效

关键问题

  • 线程 1 修改数据 A
  • 线程 2 访问数据 B
  • 数据 A 和 B 完全不同,但在同一缓存行
  • 修改 A 导致整个缓存行失效
  • 线程 2 的缓存被迫重新加载(Cache Miss)

实验:False Sharing 的性能影响
// 代码示例:FalseSharingDemo.cs

//  有 False Sharing 的版本
class BadCounter
{
    public long Counter1;  // 8 字节
    public long Counter2;  // 8 字节(在同一缓存行中!)
}

static void DemonstrateFalseSharing()
{
    var bad = new BadCounter();

    var sw = Stopwatch.StartNew();

    var t1 = new Thread(() =>
    {
        for (int i = 0; i < 100_000_000; i++)
        {
            bad.Counter1++;  // 线程 1 修改 Counter1
        }
    });

    var t2 = new Thread(() =>
    {
        for (int i = 0; i < 100_000_000; i++)
        {
            bad.Counter2++;  // 线程 2 修改 Counter2
        }
    });

    t1.Start();
    t2.Start();
    t1.Join();
    t2.Join();

    sw.Stop();
    Console.WriteLine($"有 False Sharing:{sw.ElapsedMilliseconds}ms");
}

//  避免 False Sharing 的版本
[StructLayout(LayoutKind.Explicit)]
class GoodCounter
{
    [FieldOffset(0)]
    public long Counter1;   // 偏移 0

    // 填充 56 字节(64 - 8 = 56)
    [FieldOffset(64)]
    public long Counter2;   // 偏移 64(在下一个缓存行)
}

static void DemonstrateNoPadding()
{
    var good = new GoodCounter();

    var sw = Stopwatch.StartNew();

    var t1 = new Thread(() =>
    {
        for (int i = 0; i < 100_000_000; i++)
        {
            good.Counter1++;
        }
    });

    var t2 = new Thread(() =>
    {
        for (int i = 0; i < 100_000_000; i++)
        {
            good.Counter2++;
        }
    });

    t1.Start();
    t2.Start();
    t1.Join();
    t2.Join();

    sw.Stop();
    Console.WriteLine($"无 False Sharing:{sw.ElapsedMilliseconds}ms");
}

实际运行结果

有 False Sharing:约 8000ms
无 False Sharing:约 800ms

性能提升:10 倍!

False Sharing 的解决方案

方案 1:填充(Padding)

class PaddedCounter
{
    public long Counter1;

    // 填充 56 字节,确保 Counter2 在下一个缓存行
    private long _padding1, _padding2, _padding3, _padding4, _padding5, _padding6, _padding7;

    public long Counter2;
}

方案 2:使用 [StructLayout] 特性

[StructLayout(LayoutKind.Explicit, Size = 128)]  // 两个缓存行
struct CacheLine
{
    [FieldOffset(0)]
    public long Counter1;

    [FieldOffset(64)]  // 64 字节后,确保在下一个缓存行
    public long Counter2;
}

方案 3:使用线程本地存储(Thread-Local Storage)

class ThreadLocalCounter
{
    private ThreadLocal<long> _counter = new(() => 0);

    public void Increment()
    {
        _counter.Value++;
    }

    public long GetTotal()
    {
        // 最后合并所有线程的计数
        return _counter.Values.Sum();
    }
}

实际应用中的 False Sharing 案例

案例 1:数组元素访问

//  错误:多个线程修改相邻数组元素
long[] counters = new long[4];

Parallel.For(0, 4, i =>
{
    for (int j = 0; j < 1000000; j++)
    {
        counters[i]++;  // False Sharing!
    }
});

//  正确:使用填充
long[] counters = new long[4 * 8];  // 每个计数器占 8 个 long(64 字节)

Parallel.For(0, 4, i =>
{
    for (int j = 0; j < 1000000; j++)
    {
        counters[i * 8]++;  // 每个计数器相隔 64 字节
    }
});

案例 2:多线程计数器

// .NET 的 Interlocked 类已经考虑了 False Sharing
// 但自定义计数器需要注意

//  错误
class MultiCounter
{
    public int Count1;
    public int Count2;
    public int Count3;
    public int Count4;
}

//  正确
[StructLayout(LayoutKind.Explicit)]
class PaddedMultiCounter
{
    [FieldOffset(0)]
    public int Count1;

    [FieldOffset(64)]
    public int Count2;

    [FieldOffset(128)]
    public int Count3;

    [FieldOffset(192)]
    public int Count4;
}

常见导致上下文切换的场景

了解哪些操作会触发上下文切换,可以帮助我们编写更高效的多线程代码。

1. 阻塞 I/O 操作
//  阻塞式 I/O:导致上下文切换
void BlockingIo()
{
    // 线程阻塞,等待网络响应
    var response = httpClient.GetStringAsync(url).Result;

    // 线程阻塞,等待文件读取
    var data = File.ReadAllText(filePath);
}

//  异步 I/O:不阻塞线程
async Task AsyncIo()
{
    // 线程释放,不阻塞
    var response = await httpClient.GetStringAsync(url);

    // 线程释放,不阻塞
    var data = await File.ReadAllTextAsync(filePath);
}

// 性能影响:
// - 阻塞 I/O:线程等待期间发生上下文切换(浪费线程资源)
// - 异步 I/O:线程立即释放,可以处理其他任务

2. 锁竞争(Lock Contention)
//  高锁竞争:频繁上下文切换
object _lock = new();

void HighContention()
{
    // 100 个线程竞争同一个锁
    Parallel.For(0, 100, i =>
    {
        lock (_lock)  // 大部分线程会阻塞,等待锁释放
        {
            // 临界区
            Thread.Sleep(10);  // 模拟工作
        }
    });
}

//  减少锁粒度:降低竞争
class ShardedLock
{
    private readonly object[] _locks = new object[16];

    public ShardedLock()
    {
        for (int i = 0; i < _locks.Length; i++)
        {
            _locks[i] = new object();
        }
    }

    public void Execute(int id, Action action)
    {
        // 根据 ID 选择不同的锁(减少竞争)
        var lockIndex = id % _locks.Length;
        lock (_locks[lockIndex])
        {
            action();
        }
    }
}

// 性能影响:
// - 高竞争:大量线程阻塞 → 频繁上下文切换 → 性能下降 50-90%
// - 分片锁:竞争减少 → 上下文切换减少 → 性能提升 5-10 倍

3. Thread.Sleep() 和 Thread.Yield()
//  Thread.Sleep(0):主动触发上下文切换
void ForceContextSwitch()
{
    for (int i = 0; i < 1000000; i++)
    {
        DoWork();
        Thread.Sleep(0);  // 强制上下文切换
    }
}

// ️ Thread.Sleep(n):线程休眠,触发上下文切换
void SleepExample()
{
    Thread.Sleep(100);  // 线程休眠 100ms,让出 CPU
}

// ️ Thread.Yield():主动让出 CPU 时间片
void YieldExample()
{
    Thread.Yield();  // 让出当前时间片给其他线程
}

// 性能影响:
// - 频繁 Sleep(0):每次调用都触发上下文切换
// - 实验结果:见上文 ContextSwitchCostDemo(慢 100 倍)

4. 等待操作(Wait / Join)
//  同步等待:线程阻塞
void SyncWait()
{
    var task = Task.Run(() => LongRunningWork());
    task.Wait();  // 线程阻塞,触发上下文切换
}

void ThreadJoin()
{
    var thread = new Thread(() => LongRunningWork());
    thread.Start();
    thread.Join();  // 线程阻塞,触发上下文切换
}

//  异步等待:不阻塞线程
async Task AsyncWait()
{
    var task = Task.Run(() => LongRunningWork());
    await task;  // 不阻塞线程,不触发上下文切换
}

// 性能影响:
// - task.Wait():阻塞当前线程 → 上下文切换 → 浪费线程资源
// - await task:释放当前线程 → 无上下文切换 → 高效利用线程池

5. 信号量和事件等待
// ️ ManualResetEvent / AutoResetEvent:阻塞式等待
void EventWait()
{
    var resetEvent = new ManualResetEvent(false);

    var t1 = new Thread(() =>
    {
        resetEvent.WaitOne();  // 线程阻塞,等待信号
        DoWork();
    });

    t1.Start();
    Thread.Sleep(1000);
    resetEvent.Set();  // 唤醒等待的线程(触发上下文切换)
}

//  SemaphoreSlim.WaitAsync():异步等待
async Task SemaphoreWaitAsync()
{
    var semaphore = new SemaphoreSlim(0);

    var task = Task.Run(async () =>
    {
        await semaphore.WaitAsync();  // 不阻塞线程
        DoWork();
    });

    await Task.Delay(1000);
    semaphore.Release();  // 释放信号
}

// 性能影响:
// - WaitOne():阻塞 → 上下文切换(约 2-7 微秒)
// - WaitAsync():不阻塞 → 无上下文切换

6. 线程数量过多
//  过多线程:频繁上下文切换
void TooManyThreads()
{
    for (int i = 0; i < 1000; i++)
    {
        new Thread(() =>
        {
            while (true)
            {
                DoWork();
                Thread.Sleep(10);
            }
        }).Start();
    }
}

//  使用线程池:线程数量受控
void UseThreadPool()
{
    for (int i = 0; i < 1000; i++)
    {
        ThreadPool.QueueUserWorkItem(_ =>
        {
            DoWork();
        });
    }
}

// 性能影响:
// - 1000 个线程 × 100 次切换/秒 = 100000 次上下文切换/秒
// - 每次切换 50 微秒 = 5 秒/秒(50% 时间在切换!)

7. 频繁的 Task.Run
//  过度使用 Task.Run:增加调度开销
async Task OveruseTaskRun()
{
    for (int i = 0; i < 10000; i++)
    {
        await Task.Run(() => DoSmallWork());  // 小任务,频繁调度
    }
}

//  批量处理:减少调度次数
async Task BatchProcessing()
{
    var tasks = Enumerable.Range(0, 10000)
        .Select(i => Task.Run(() => DoSmallWork()))
        .ToArray();

    await Task.WhenAll(tasks);
}

// 或者:直接在当前线程执行(如果是 CPU 密集型)
void DirectExecution()
{
    for (int i = 0; i < 10000; i++)
    {
        DoSmallWork();  // 如果工作很小,不值得调度
    }
}

// 性能影响:
// - 每次 Task.Run:约 1-5 微秒调度开销
// - 10000 次 = 10-50ms 开销

避免上下文切换的最佳实践

总结表格

场景 问题 解决方案 性能提升
阻塞 I/O 线程等待期间阻塞 使用 async/await 线程利用率提升 10-100 倍
锁竞争 多线程竞争同一锁 分片锁 / 无锁数据结构 吞吐量提升 5-10 倍
过多线程 频繁上下文切换 使用线程池(ThreadPool) 减少 50-90% 上下文切换
频繁 Sleep 主动触发切换 避免 Sleep(0) 性能提升 10-100 倍
同步等待 阻塞线程 使用异步方法(WaitAsync 线程利用率提升 5-10 倍
False Sharing 缓存行伪共享 填充 / 线程本地存储 性能提升 5-10 倍
小任务调度 调度开销过大 批量处理 / 直接执行 减少 50-90% 调度开销

关键原则

  1. I/O 密集型:使用 async/await(不占用线程)
  2. CPU 密集型:使用 ThreadPool / Task.Run(线程复用)
  3. 避免阻塞:永远不要在异步方法中使用 .Result.Wait()
  4. 减少锁粒度:使用细粒度锁或无锁数据结构
  5. 控制线程数:不要无限创建线程
  6. 避免 False Sharing:使用填充或线程本地存储


1.3 为什么不能无限创建线程?

限制 1:内存限制

假设:
- 机器内存:16 GB
- 每个线程:1 MB

理论上限:16 GB / 1 MB = 16000 个线程

实际上限:约 2000-5000 个(OS 保留、其他进程占用)

限制 2:调度器性能下降

线程数量 → 调度开销
100 个线程:调度开销 < 1%
1000 个线程:调度开销 约 5-10%
10000 个线程:调度开销 > 50%(大部分时间在切换!)

限制 3:饥饿问题

// 反例:线程饥饿
for (int i = 0; i < 10000; i++)
{
    new Thread(() => 
    {
        // 每个线程都想获取同一个锁
        lock (sharedLock)
        {
            // 临界区
        }
    }).Start();
}

// 问题:
// - 大量线程竞争锁
// - 大部分线程处于等待状态
// - CPU 浪费在上下文切换上

1.4 Thread 的适用场景

尽管有这些限制,Thread 仍然有其用武之地:

场景 是否适合 Thread
短期任务(< 100ms) 不适合(创建成本高)
长期后台任务 适合(如监控线程)
I/O 密集型 不适合(阻塞线程浪费)
CPU 密集型(大量) 不适合(应使用线程池)
需要精确控制线程 适合(如设置优先级、Apartment)

小结

Thread 是强大但昂贵的资源。在现代 .NET 开发中,应该优先使用 ThreadPool 或 Task,只在必要时使用 Thread。


Part 2: ThreadPool 的精妙设计

2.1 ThreadPool 的核心思想

问题:创建/销毁线程太昂贵

解决方案线程池(Thread Pool)

核心思想:
1. 预先创建一组线程
2. 任务来了,从池中取一个线程执行
3. 任务完成后,线程归还池中(不销毁)
4. 复用线程,避免频繁创建/销毁

2.2 .NET Framework vs .NET Core 的架构对比

.NET Framework 4.x 的 ThreadPool

特点

  • 全局工作队列(FIFO)
  • 性能瓶颈:所有线程竞争同一个全局队列锁
  • 没有工作窃取:空闲线程只能等待
  • 负载不均衡:某些线程忙碌,某些线程空闲

架构图

flowchart TD A[用户代码] –> B[ThreadPool.QueueUserWorkItem] B –> C[全局队列<br/>有锁] C –> D[工作线程 1] C –> E[工作线程 2] C –> F[工作线程 3] C –> G[工作线程 N] D –> H[执行任务] E –> I[执行任务] F –> J[执行任务] G –> K[执行任务] H –> C I –> C J –> C K –> C style C fill:#ffccbc,color:#b71c1c,stroke:#d32f2f,stroke-width:3px Note1[️ 所有线程竞争同一个锁<br/>高并发时性能瓶颈]

性能问题示意

sequenceDiagram participant T1 as 线程 1 participant T2 as 线程 2 participant T3 as 线程 3 participant Q as 全局队列<br/>(有锁) Note over T1,Q: 多个线程同时尝试获取任务 T1->>Q: 尝试获取锁 T2->>Q: 尝试获取锁 T3->>Q: 尝试获取锁 Q–>>T1: 获取锁成功 Q–>>T2: 阻塞等待 Q–>>T3: 阻塞等待 T1->>Q: 取出任务 A T1->>Q: 释放锁 Q–>>T2: 获取锁成功 Q–>>T3: 继续等待 Note over T2,T3: 频繁的锁竞争<br/>降低吞吐量


.NET Core / .NET 5+ 的 ThreadPool

特点

  • 每个线程有本地队列(Thread-Local Queue)
  • 工作窃取算法(Work Stealing)
  • 无锁或低锁设计(Lock-Free / Low-Lock)
  • 负载均衡:自动平衡线程间的任务
  • 性能提升:相比 .NET Framework 提升 4-10 倍

架构图

flowchart TD A[用户代码] –> B[ThreadPool.QueueUserWorkItem] B –> C{当前线程是<br/>ThreadPool 线程?} C –>|是<br/>例如: Task 内部再提交 Task| D[放入本地队列<br/>LIFO 无锁] C –>|否<br/>例如: 主线程/UI 线程提交| E[放入全局队列<br/>低锁] D –> F[线程 1<br/>本地队列] E –> G[全局队列<br/>ConcurrentQueue] G –> F G –> H[线程 2<br/>本地队列] G –> I[线程 3<br/>本地队列] F –>|执行任务| J[工作 1] H –>|执行任务| K[工作 2] I –>|执行任务| L[工作 3] F -.窃取.-> H F -.窃取.-> I H -.窃取.-> F H -.窃取.-> I I -.窃取.-> F I -.窃取.-> H style D fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style E fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style F fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style H fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style I fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px Note1[ 本地队列无锁,性能极高<br/> 工作窃取实现负载均衡]

判断逻辑详解

//  场景 1:主线程提交 → 全局队列
static void Main()
{
    // 当前线程:主线程(非 ThreadPool 线程)
    ThreadPool.QueueUserWorkItem(_ => 
    {
        Console.WriteLine("任务 A");
    });
    // ↑ 任务 A 进入全局队列
}

//  场景 2:ThreadPool 线程内部提交 → 本地队列
static void Main()
{
    ThreadPool.QueueUserWorkItem(_ => 
    {
        // 当前线程:ThreadPool 线程
        Console.WriteLine("任务 A");

        ThreadPool.QueueUserWorkItem(_ => 
        {
            Console.WriteLine("任务 B");
        });
        // ↑ 任务 B 进入当前线程的本地队列(LIFO 无锁)
    });
}

//  场景 3:Task 内部提交 Task → 本地队列
static async Task ProcessAsync()
{
    await Task.Run(() =>
    {
        // 当前线程:ThreadPool 线程
        Console.WriteLine("任务 A");

        Task.Run(() => 
        {
            Console.WriteLine("任务 B");
        });
        // ↑ 任务 B 进入当前线程的本地队列
    });
}

//  场景 4:UI 线程提交 → 全局队列
private void Button_Click(object sender, EventArgs e)
{
    // 当前线程:UI 线程(非 ThreadPool 线程)
    Task.Run(() => 
    {
        Console.WriteLine("后台任务");
    });
    // ↑ 任务进入全局队列
}

底层实现(简化)

// .NET Core ThreadPool 内部逻辑
public void QueueUserWorkItem(WaitCallback callback)
{
    // 检查当前线程是否是 ThreadPool 线程
    var currentThreadLocalQueue = t_queue;  // [ThreadStatic] 线程本地变量

    if (currentThreadLocalQueue != null)
    {
        // 当前是 ThreadPool 线程 → 放入本地队列
        currentThreadLocalQueue.LocalPush(callback);  // LIFO 无锁
    }
    else
    {
        // 当前不是 ThreadPool 线程 → 放入全局队列
        _globalQueue.Enqueue(callback);  // FIFO 低锁
    }
}

性能影响

提交场景 目标队列 锁开销 缓存友好性 性能
主线程 → ThreadPool 全局队列 低锁(ConcurrentQueue) 一般
ThreadPool → ThreadPool 本地队列 无锁 极好 极高
UI 线程 → ThreadPool 全局队列 低锁 一般

关键优化

  • 本地队列优势

    • 无锁设计,零竞争
    • 缓存友好(刚 Push 的数据可能还在 CPU 缓存中)
    • LIFO 策略进一步提升缓存命中率
  • 全局队列特点

    • 使用 ConcurrentQueue(低锁,非无锁)
    • 多个非 ThreadPool 线程可能同时提交(需要同步)
    • 作为”托底”机制,确保所有任务都能入队

2.3 工作窃取算法(Work Stealing)详解

2.3.1 工作窃取解决的问题

问题场景

flowchart LR A[线程 1] –>|10 个任务| B[忙碌<br/>CPU 100%] C[线程 2] –>|0 个任务| D[空闲<br/>CPU 0%] E[线程 3] –>|8 个任务| F[忙碌<br/>CPU 90%] style B fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style D fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style F fill:#ffe0b2,color:#e65100,stroke:#ef6c00,stroke-width:2px Note1[ 负载不均衡<br/> CPU 利用率低<br/> 任务完成慢]

传统解决方案(.NET Framework)

线程 2(空闲)→ 等待全局队列有新任务
    ↓
问题:
- 如果没有新任务提交,线程 2 一直空闲
- 线程 1 的任务完不成,整体吞吐量下降

工作窃取解决方案(.NET Core)

线程 2(空闲)→ 主动从线程 1 的队列中"偷"任务
    ↓
优点:
- 自动负载均衡
- 提高 CPU 利用率
- 加速任务完成

2.3.2 工作窃取的完整流程

sequenceDiagram participant T1 as 线程 1<br/>(忙碌) participant Q1 as 本地队列 1<br/>[A,B,C,D,E] participant T2 as 线程 2<br/>(空闲) participant Q2 as 本地队列 2<br/>[ ] participant G as 全局队列 Note over T1,Q1: 线程 1 正在执行任务 T1->>Q1: 从尾部取出任务 E<br/>(LIFO) Q1–>>T1: 返回任务 E Note over T2,Q2: 线程 2 完成任务,本地队列为空 T2->>Q2: 尝试从本地队列取任务 Q2–>>T2: 队列为空 Note over T2,G: 步骤 2:尝试从全局队列取任务 T2->>G: 尝试从全局队列取任务 G–>>T2: 全局队列也为空 Note over T2,Q1: 步骤 3:工作窃取触发! T2->>Q1: 从头部偷取任务 A<br/>(FIFO) Q1–>>T2: 返回任务 A Note over T1,T2: 现在两个线程都在工作 T1->>T1: 执行任务 E T2->>T2: 执行任务 A Note over Q1: 本地队列 1 剩余:[B,C,D]


2.3.3 本地队列的双端设计

为什么本线程用 LIFO,窃取用 FIFO?

flowchart TD A[本地队列:双端队列 Deque] –> B[尾部<br/>LIFO<br/>本线程访问] A –> C[头部<br/>FIFO<br/>其他线程窃取] B –> D[最近加入的任务<br/>缓存热] C –> E[最早加入的任务<br/>缓存冷] D –> F[ 优点 1:缓存友好<br/>本线程刚 Push 的数据<br/>可能还在 CPU 缓存中] E –> G[ 优点 2:减少竞争<br/>本线程和窃取线程<br/>访问不同端] style B fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style C fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style F fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style G fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px

代码层面的体现

// .NET Core ThreadPool 内部的简化实现(伪代码)

class ThreadPoolWorkQueue
{
    // 每个线程的本地队列(双端队列)
    [ThreadStatic]
    private static WorkStealingQueue? t_queue;

    // 全局队列
    private readonly ConcurrentQueue<IThreadPoolWorkItem> _globalQueue;

    public void Enqueue(IThreadPoolWorkItem item)
    {
        // 如果有本地队列,优先放入本地队列
        if (t_queue != null)
        {
            t_queue.LocalPush(item);  // LIFO:从尾部加入
        }
        else
        {
            // 没有本地队列,放入全局队列
            _globalQueue.Enqueue(item);
        }
    }

    public bool TryDequeue(out IThreadPoolWorkItem? item)
    {
        // 1. 先尝试从本地队列取(LIFO)
        if (t_queue != null && t_queue.LocalPop(out item))
        {
            return true;  // 从尾部取出
        }

        // 2. 本地队列空了,从全局队列取
        if (_globalQueue.TryDequeue(out item))
        {
            return true;
        }

        // 3. 全局队列也空了,尝试"偷"其他线程的任务
        return TrySteal(out item);
    }

    private bool TrySteal(out IThreadPoolWorkItem? item)
    {
        // 遍历所有其他线程的本地队列
        foreach (var queue in AllThreadLocalQueues)
        {
            if (queue != t_queue && queue.TrySteal(out item))
            {
                return true;  // FIFO:从头部偷取
            }
        }

        item = null;
        return false;
    }
}

class WorkStealingQueue
{
    private IThreadPoolWorkItem[] _array;
    private volatile int _headIndex;  // 头部索引(窃取端)
    private volatile int _tailIndex;  // 尾部索引(本线程端)

    // 本线程 Push(LIFO):从尾部加入
    public void LocalPush(IThreadPoolWorkItem item)
    {
        int tail = _tailIndex;
        _array[tail] = item;
        _tailIndex = tail + 1;
    }

    // 本线程 Pop(LIFO):从尾部取出
    public bool LocalPop(out IThreadPoolWorkItem? item)
    {
        int tail = _tailIndex - 1;
        if (tail < _headIndex)
        {
            item = null;
            return false;
        }

        _tailIndex = tail;
        item = _array[tail];
        return true;
    }

    // 其他线程窃取(FIFO):从头部取出
    public bool TrySteal(out IThreadPoolWorkItem? item)
    {
        int head = _headIndex;
        if (head >= _tailIndex)
        {
            item = null;
            return false;
        }

        // 使用原子操作避免竞争
        if (Interlocked.CompareExchange(ref _headIndex, head + 1, head) == head)
        {
            item = _array[head];
            return true;
        }

        item = null;
        return false;
    }
}

2.3.4 工作窃取的实际场景演示

场景:并行处理 100 个任务

sequenceDiagram participant U as 用户代码 participant TP as ThreadPool participant T1 as 线程 1 participant T2 as 线程 2 participant T3 as 线程 3 participant Q1 as 队列 1 participant Q2 as 队列 2 participant Q3 as 队列 3 Note over U,TP: 提交 100 个任务 U->>TP: 提交任务 1-100 TP->>Q1: 任务 1-33 TP->>Q2: 任务 34-66 TP->>Q3: 任务 67-100 Note over T1,Q1: 线程 1:33 个任务 Note over T2,Q2: 线程 2:33 个任务 Note over T3,Q3: 线程 3:34 个任务 par 并行执行 T1->>Q1: 执行任务 1-10 T2->>Q2: 执行任务 34-43 T3->>Q3: 执行任务 67-76 end Note over T2,Q2: 线程 2 完成得快,队列空了 T2->>Q2: 尝试取任务 Q2–>>T2: 队列为空 Note over T2,Q3: 工作窃取:从线程 3 偷取 T2->>Q3: 窃取任务 77-85<br/>(偷一半) Q3–>>T2: 返回任务 par 继续并行执行 T1->>Q1: 执行任务 11-33 T2->>T2: 执行任务 77-85 T3->>Q3: 执行任务 86-100 end Note over T1,T3: 所有任务完成<br/>负载自动均衡


2.3.5 工作窃取的性能优势

性能对比实验

场景 .NET Framework 4.8 .NET Core / .NET 5+ 性能提升
少量任务(10 个) 5 ms 4 ms 约 1.25 倍
中等任务(1000 个) 200 ms 50 ms 约 4 倍
大量任务(100000 个) 10000 ms 1000 ms 约 10 倍
不均衡任务 15000 ms 2000 ms 约 7.5 倍

不均衡任务场景

// 模拟不均衡任务:某些任务耗时长,某些任务耗时短
for (int i = 0; i < 1000; i++)
{
    int taskId = i;
    ThreadPool.QueueUserWorkItem(_ =>
    {
        // 随机耗时:10-1000ms
        Thread.Sleep(Random.Shared.Next(10, 1000));
    });
}

// .NET Framework:
// - 某些线程可能一直执行短任务(快速完成)
// - 某些线程可能一直执行长任务(阻塞很久)
// - 无法自动平衡

// .NET Core(工作窃取):
// - 完成短任务的线程会自动"偷"长任务线程的任务
// - 自动负载均衡
// - 整体完成时间大幅缩短

2.3.6 工作窃取的代码示例

完整的模拟实现(简化版):

// 代码示例:WorkStealingDemo.cs
// 注意:这是教学用的简化版,真实实现更复杂

class WorkStealingQueue
{
    private readonly ConcurrentQueue<Action> _globalQueue = new();
    private readonly ThreadLocal<Queue<Action>> _localQueue = new(() => new Queue<Action>());

    // 所有线程的本地队列(用于窃取)
    private static readonly ConcurrentBag<Queue<Action>> AllQueues = new();

    public WorkStealingQueue()
    {
        // 注册本地队列
        AllQueues.Add(_localQueue.Value!);
    }

    public void Enqueue(Action action)
    {
        // 优先放入本地队列(LIFO)
        _localQueue.Value!.Enqueue(action);

        // 如果本地队列过大,转移一部分到全局队列
        if (_localQueue.Value.Count > 100)
        {
            for (int i = 0; i < 50; i++)
            {
                if (_localQueue.Value.TryDequeue(out var item))
                {
                    _globalQueue.Enqueue(item);
                }
            }
        }
    }

    public bool TryDequeue(out Action? action)
    {
        // 1. 先尝试从本地队列拿(LIFO)
        if (_localQueue.Value!.Count > 0 && _localQueue.Value.TryDequeue(out action!))
        {
            return true;
        }

        // 2. 本地队列空了,从全局队列拿
        if (_globalQueue.TryDequeue(out action!))
        {
            return true;
        }

        // 3. 全局队列也空了,尝试"偷"其他线程的任务(FIFO)
        foreach (var queue in AllQueues)
        {
            if (queue != _localQueue.Value && queue.Count > 0)
            {
                lock (queue)  // 简化实现,实际使用无锁算法
                {
                    if (queue.TryDequeue(out action!))
                    {
                        return true;
                    }
                }
            }
        }

        action = null;
        return false;
    }
}

// 使用示例
static void DemonstrateWorkStealing()
{
    var queue = new WorkStealingQueue();
    var completed = 0;

    // 提交 1000 个任务
    for (int i = 0; i < 1000; i++)
    {
        int taskId = i;
        queue.Enqueue(() =>
        {
            // 模拟工作
            Thread.Sleep(Random.Shared.Next(1, 10));
            Interlocked.Increment(ref completed);
            Console.WriteLine($"任务 {taskId} 完成,线程 {Environment.CurrentManagedThreadId}");
        });
    }

    // 启动 4 个工作线程
    var workers = new Thread[4];
    for (int i = 0; i < 4; i++)
    {
        workers[i] = new Thread(() =>
        {
            while (completed < 1000)
            {
                if (queue.TryDequeue(out var action))
                {
                    action();
                }
                else
                {
                    Thread.Sleep(1);  // 短暂休眠,等待新任务
                }
            }
        });
        workers[i].Start();
    }

    // 等待所有线程完成
    foreach (var worker in workers)
    {
        worker.Join();
    }

    Console.WriteLine($"所有任务完成!总计:{completed} 个");
}

2.4 ThreadPool 的任务调度流程

2.4.1 任务提交的完整流程

flowchart TD A[用户代码] –> B{在 ThreadPool 线程中?} B –>|是| C[放入当前线程的<br/>本地队列<br/>LIFO 无锁] B –>|否| D[放入全局队列<br/>ConcurrentQueue] C –> E[本地队列] D –> F[全局队列] E –> G[线程 1] E –> H[线程 2<br/>可以窃取] E –> I[线程 3<br/>可以窃取] F –> G F –> H F –> I G –> J[执行任务] H –> K[执行任务] I –> L[执行任务] J –> M{本地队列空?} K –> N{本地队列空?} L –> O{本地队列空?} M –>|是| P[尝试全局队列] N –>|是| P O –>|是| P P –> Q{全局队列空?} Q –>|是| R[工作窃取] Q –>|否| F R –> E style C fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style D fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style R fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px

2.4.2 线程调度的优先级

线程获取任务的优先级顺序

flowchart LR A[线程空闲] –> B{1.本地队列有任务?} B –>|是| C[ 从本地队列取<br/>LIFO 无锁<br/>最快] B –>|否| D{2.全局队列有任务?} D –>|是| E[ 从全局队列取<br/>FIFO 低锁<br/>较快] D –>|否| F{3.其他线程有任务?} F –>|是| G[ 工作窃取<br/>FIFO 低锁<br/>慢] F –>|否| H[ 线程休眠<br/>等待新任务] C –> I[执行任务] E –> I G –> I I –> A H –> J[新任务到来] J –> A style C fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style E fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style G fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style H fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px

优先级总结

  1. 最高优先级:本地队列(LIFO,无锁,缓存友好)
  2. 次优先级:全局队列(FIFO,低锁)
  3. 兜底策略:工作窃取(FIFO,从其他线程偷取)
  4. 无任务时:线程休眠,等待新任务唤醒

2.5 ThreadPool 的动态调整

2.5.1 MinThreads 和 MaxThreads

// 查看当前配置
ThreadPool.GetMinThreads(out int minWorker, out int minIO);
ThreadPool.GetMaxThreads(out int maxWorker, out int maxIO);

Console.WriteLine($"最小工作线程:{minWorker}");
Console.WriteLine($"最大工作线程:{maxWorker}");
Console.WriteLine($"最小 I/O 线程:{minIO}");
Console.WriteLine($"最大 I/O 线程:{maxIO}");

// 典型输出(8 核 CPU):
// 最小工作线程:8(通常 = CPU 核心数)
// 最大工作线程:32767
// 最小 I/O 线程:8
// 最大 I/O 线程:1000

配置说明

参数 默认值 说明
MinThreads CPU 核心数 线程池启动时立即创建的线程数
MaxThreads 32767(工作线程) 线程池可以创建的最大线程数
I/O Threads 单独的 I/O 完成端口线程 用于处理异步 I/O 完成

2.5.2 线程注入策略(Hill Climbing 算法)

场景:任务突然增加

sequenceDiagram participant U as 用户代码 participant TP as ThreadPool participant HC as Hill Climbing<br/>算法 participant OS as 操作系统 Note over U,TP: T0: 初始状态 U->>TP: 提交 100 个任务 TP->>TP: 当前线程:8 个 (MinThreads) TP->>TP: 所有线程都忙碌 Note over TP,HC: T1: 500ms 后 TP->>HC: 检测:所有线程忙碌<br/>任务队列积压 HC->>HC: 计算吞吐量:100 tasks/s HC->>TP: 决策:注入 1 个新线程 TP->>OS: 创建新线程 9 Note over TP,HC: T2: 1000ms 后(再等 500ms) TP->>HC: 检测:仍然忙碌 HC->>HC: 计算吞吐量:120 tasks/s ↑ HC->>TP: 决策:注入 1 个新线程 TP->>OS: 创建新线程 10 Note over TP,HC: T3: 1500ms 后 TP->>HC: 检测:仍然忙碌 HC->>HC: 计算吞吐量:150 tasks/s ↑ HC->>TP: 决策:注入 2 个新线程<br/>(加速注入) TP->>OS: 创建新线程 11, 12 Note over TP,HC: T4: 2000ms 后 TP->>HC: 检测:仍然忙碌 HC->>HC: 计算吞吐量:140 tasks/s ↓ HC->>TP: 决策:停止注入<br/>(吞吐量下降) Note over TP: 线程数稳定在 12 个

Hill Climbing 算法原理

flowchart TD A[检测线程池状态] –> B{所有线程忙碌?} B –>|否| C[保持当前线程数] B –>|是| D[等待 500ms<br/>观察吞吐量] D –> E[测量吞吐量<br/>Throughput] E –> F{吞吐量变化?} F –>|上升 ↑| G[ 注入新线程<br/>继续爬坡] F –>|下降 ↓| H[ 停止注入<br/>线程数已达最优] F –>|持平 →| I[️ 小幅调整<br/>继续观察] G –> J[创建 1-2 个新线程] J –> A H –> C I –> A C –> K[持续监控] K –> A style G fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style H fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style I fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px

Hill Climbing 核心逻辑

️ 注意:上面流程图中的时间间隔(500ms)是简化值,便于理解。实际的检测间隔是动态的,从初始的 15-30ms 逐渐增加到数百毫秒甚至数秒,具体取决于系统负载和吞吐量变化。

  1. 目标:找到最优线程数量,使吞吐量最大化
  2. 策略:逐步增加线程,监控吞吐量变化
  3. 决策
    • 吞吐量上升 → 继续增加线程
    • 吞吐量下降 → 停止增加(过多线程导致上下文切换)
    • 吞吐量持平 → 小幅调整,继续观察

为什么不立即创建大量线程?

假设立即创建 100 个线程:
    ↓
 内存占用:100 MB
 上下文切换:频繁
 CPU 缓存失效:严重
 吞吐量下降:可能比 10 个线程还慢

Hill Climbing 的智能之处

逐步增加线程:
    ↓
 自动找到最优点
 避免过度创建
 适应不同工作负载
 动态调整(任务减少时会减少线程)

2.5.3 线程回收策略

空闲线程的生命周期

stateDiagram-v2 [*] –> Working: 执行任务 Working –> Idle: 任务完成 Idle –> Working: 有新任务 Idle –> Waiting: 等待 20 秒 Waiting –> Working: 有新任务 Waiting –> Terminated: 超时仍无任务<br/>且线程数 > MinThreads Terminated –> [*] note right of Idle 线程空闲,等待新任务 end note note right of Waiting 超过 20 秒无任务 准备终止 end note note right of Terminated 线程销毁 释放资源 end note

线程回收条件

// 伪代码:ThreadPool 内部逻辑

while (true)
{
    if (TryGetWork(out var workItem))
    {
        // 有任务,执行
        workItem.Execute();
    }
    else
    {
        // 无任务,空闲
        if (WaitForWork(timeout: 20_000))  // 等待 20 秒
        {
            // 有新任务到来,继续工作
            continue;
        }
        else
        {
            // 20 秒后仍无任务
            if (ThreadPool.ThreadCount > ThreadPool.MinThreads)
            {
                // 线程数超过最小值,可以退出
                Console.WriteLine($"线程 {Environment.CurrentManagedThreadId} 退出");
                return;  // 线程终止
            }
            else
            {
                // 线程数等于最小值,不能退出,继续等待
                continue;
            }
        }
    }
}

2.5.4 实验:观察线程池动态调整

// 代码示例:ThreadPoolDynamicDemo.cs
static void DemonstrateThreadPoolDynamic()
{
    ThreadPool.SetMinThreads(2, 2);  // 设置最小线程数为 2

    Console.WriteLine("开始提交任务...");

    for (int i = 0; i < 50; i++)
    {
        int taskId = i;
        ThreadPool.QueueUserWorkItem(_ =>
        {
            Console.WriteLine($"[任务 {taskId}] 线程 {Environment.CurrentManagedThreadId} 开始");
            Thread.Sleep(2000);  // 模拟工作
            Console.WriteLine($"[任务 {taskId}] 完成");
        });

        if (i % 10 == 0)
        {
            ThreadPool.GetAvailableThreads(out int worker, out int io);
            Console.WriteLine($"--- 已提交 {i} 个任务,当前线程池线程数:{ThreadPool.ThreadCount},可用:{worker} ---");
        }

        Thread.Sleep(100);  // 稍微延迟,观察线程注入
    }

    Console.WriteLine("\n等待所有任务完成...");
    Thread.Sleep(10000);

    ThreadPool.GetAvailableThreads(out int finalWorker, out int finalIo);
    Console.WriteLine($"\n最终线程池状态:");
    Console.WriteLine($"  线程数:{ThreadPool.ThreadCount}");
    Console.WriteLine($"  可用工作线程:{finalWorker}");
}

// 预期输出:
// --- 已提交 0 个任务,当前线程池线程数:2,可用:... ---
// [任务 0] 线程 4 开始
// [任务 1] 线程 5 开始
// (等待 500ms,Hill Climbing 决策)
// --- 已提交 10 个任务,当前线程池线程数:3,可用:... ---
// [任务 2] 线程 6 开始
// (等待 500ms)
// --- 已提交 20 个任务,当前线程池线程数:4,可用:... ---
// ...
// (逐步增加线程数)
// --- 已提交 40 个任务,当前线程池线程数:8,可用:... ---

观察要点

  1. 初始阶段:只有 2 个线程(MinThreads)
  2. 任务积压:所有线程忙碌,任务排队
  3. 逐步注入:每 500ms 注入 1-2 个新线程
  4. 达到稳定:吞吐量不再提升,停止注入
  5. 任务完成后:空闲线程在 20 秒后逐步退出

2.6 ThreadPool 的性能监控

2.6.1 实时监控 API

// 获取线程池状态
ThreadPool.GetAvailableThreads(out int availableWorker, out int availableIO);
ThreadPool.GetMinThreads(out int minWorker, out int minIO);
ThreadPool.GetMaxThreads(out int maxWorker, out int maxIO);

// 计算忙碌线程数
int busyWorker = maxWorker - availableWorker;
int busyIO = maxIO - availableIO;

// .NET 5+ 新增:获取挂起任务数
long pendingItems = ThreadPool.PendingWorkItemCount;

// .NET 7+ 新增:获取完成任务数
long completedItems = ThreadPool.CompletedWorkItemCount;

Console.WriteLine($"工作线程:{busyWorker}/{maxWorker} 忙碌");
Console.WriteLine($"I/O 线程:{busyIO}/{maxIO} 忙碌");
Console.WriteLine($"挂起任务:{pendingItems}");
Console.WriteLine($"已完成任务:{completedItems}");

2.6.2 监控示例

// 代码示例:ThreadPoolMonitorDemo.cs
static void MonitorThreadPool()
{
    var timer = new System.Timers.Timer(1000);
    timer.Elapsed += (s, e) =>
    {
        ThreadPool.GetAvailableThreads(out int worker, out int io);
        ThreadPool.GetMinThreads(out int minWorker, out int minIo);
        ThreadPool.GetMaxThreads(out int maxWorker, out int maxIo);

        int busyWorker = maxWorker - worker;
        long pending = ThreadPool.PendingWorkItemCount;

        Console.WriteLine($"[{DateTime.Now:HH:mm:ss}]");
        Console.WriteLine($"  工作线程:{busyWorker}/{maxWorker} 忙碌,{worker} 可用");
        Console.WriteLine($"  I/O 线程:{maxIo - io}/{maxIo} 忙碌");
        Console.WriteLine($"  挂起任务:{pending}");
        Console.WriteLine($"  当前线程数:{ThreadPool.ThreadCount}");
        Console.WriteLine("---");
    };
    timer.Start();

    // 提交一些任务
    for (int i = 0; i < 100; i++)
    {
        ThreadPool.QueueUserWorkItem(_ => Thread.Sleep(5000));
    }

    Console.WriteLine("按 Enter 停止监控...");
    Console.ReadLine();
    timer.Stop();
}

// 输出示例:
// [14:30:00]
//   工作线程:8/32767 忙碌,32759 可用
//   I/O 线程:0/1000 忙碌
//   挂起任务:92
//   当前线程数:8
// ---
// [14:30:01](500ms 后,Hill Climbing 注入新线程)
//   工作线程:9/32767 忙碌,32758 可用
//   I/O 线程:0/1000 忙碌
//   挂起任务:83
//   当前线程数:9
// ---

2.7 ThreadPool 的最佳实践

适合使用 ThreadPool 的场景

// 1. 短期任务(< 1 秒)
ThreadPool.QueueUserWorkItem(_ =>
{
    ProcessData();  // 快速完成
});

// 2. CPU 密集型任务(数量可控)
for (int i = 0; i < 100; i++)
{
    ThreadPool.QueueUserWorkItem(_ => ComputePrimes());
}

// 3. 并行计算
Parallel.For(0, 1000, i =>
{
    // Parallel 内部使用 ThreadPool
    ProcessItem(i);
});

不适合使用 ThreadPool 的场景

// 1. 长期运行任务(会耗尽线程池)
ThreadPool.QueueUserWorkItem(_ =>
{
    while (true)  //  永远运行,占用线程池线程
    {
        Monitor();
        Thread.Sleep(1000);
    }
});

//  正确做法:使用专用后台线程
var monitorThread = new Thread(() =>
{
    while (true)
    {
        Monitor();
        Thread.Sleep(1000);
    }
})
{
    IsBackground = true
};
monitorThread.Start();

// 2. I/O 密集型任务(应使用 async/await)
ThreadPool.QueueUserWorkItem(_ =>
{
    var data = httpClient.GetStringAsync(url).Result;  //  阻塞线程
});

//  正确做法:使用异步
await httpClient.GetStringAsync(url);  // 不阻塞线程

// 3. 需要精确控制的任务
ThreadPool.QueueUserWorkItem(_ =>
{
    //  无法设置线程优先级
    //  无法设置线程名称
    //  无法控制线程生命周期
});

//  正确做法:使用专用线程
var thread = new Thread(() => { /* work */ })
{
    Priority = ThreadPriority.High,
    Name = "Worker-1"
};
thread.Start();

2.8 性能对比:.NET Framework vs .NET Core

2.8.1 微基准测试

// 代码示例:需要分别在两个项目中运行
// 项目 1:Threads(.NET 10)
// 项目 2:ThreadsFramework(.NET Framework 4.8)

static void BenchmarkThreadPool()
{
    const int taskCount = 100_000;
    var sw = Stopwatch.StartNew();

    var countdown = new CountdownEvent(taskCount);
    for (int i = 0; i < taskCount; i++)
    {
        ThreadPool.QueueUserWorkItem(_ =>
        {
            // 简单工作
            var result = Math.Sqrt(42);
            countdown.Signal();
        });
    }

    countdown.Wait();
    sw.Stop();

    Console.WriteLine($"完成 {taskCount:N0} 个任务:{sw.ElapsedMilliseconds} ms");
    Console.WriteLine($"吞吐量:{taskCount * 1000.0 / sw.ElapsedMilliseconds:N0} tasks/s");
}

// 实际测试结果(8 核 CPU):
// .NET Framework 4.8:
//   完成 100,000 个任务:2,000 ms
//   吞吐量:50,000 tasks/s
//
// .NET Core / .NET 10:
//   完成 100,000 个任务:500 ms
//   吞吐量:200,000 tasks/s
//
// 性能提升:4 倍!

2.8.2 性能对比表

场景 .NET Framework 4.8 .NET Core / .NET 5+ 性能提升 原因
少量任务(100) 10 ms 8 ms 1.25x 锁竞争减少
中等任务(10,000) 200 ms 50 ms 4x 工作窃取
大量任务(100,000) 2,000 ms 500 ms 4x 无锁队列
不均衡任务 3,000 ms 600 ms 5x 负载均衡
高并发提交 1,500 ms 300 ms 5x 本地队列

小结

ThreadPool 通过线程复用、工作窃取算法和智能调整,大幅提升了并发性能。.NET Core 的重构使其成为现代并发编程的基石。工作窃取机制解决了负载不均衡问题,使得线程池能够自动适应各种工作负载,实现最优的 CPU 利用率。



Part 3: Task 的本质(重点)

3.1 Task 是什么?

核心观点:Task 不是线程,Task 是异步操作的抽象

Task = Promise/Future 模式的实现

Promise:承诺将来会有一个结果
Future:未来的结果

Task:
  - 可能已经完成
  - 可能正在执行
  - 可能还未开始
  - 可能失败(异常)
  - 可能被取消

Task 的状态机

可视化状态转换

stateDiagram-v2 [*] –> Created: new Task(…) Created –> WaitingForActivation: task.Start() Created –> WaitingToRun: Task.Run(…) WaitingForActivation –> WaitingToRun: 进入调度队列 WaitingToRun –> Running: 线程开始执行 Running –> RanToCompletion: 正常完成 Running –> Faulted: 抛出未处理异常 Running –> Canceled: ️ CancellationToken 取消 RanToCompletion –> [*] Faulted –> [*] Canceled –> [*] note right of Created Task 对象已创建 但尚未启动 end note note right of WaitingToRun 已加入 ThreadPool 队列 等待线程执行 end note note right of Running 正在某个线程上执行 task.IsCompleted = false end note note right of RanToCompletion 成功完成 task.IsCompletedSuccessfully = true end note note right of Faulted 异常状态 task.IsFaulted = true task.Exception 包含异常信息 end note note right of Canceled 已取消 task.IsCanceled = true end note

状态属性总结

状态 IsCompleted IsCompletedSuccessfully IsFaulted IsCanceled
Created false false false false
WaitingToRun false false false false
Running false false false false
RanToCompletion true true false false
Faulted true false true false
Canceled true false false true

代码示例

// 代码示例:TaskStateDemo.cs
static void DemonstrateTaskStates()
{
    // Created
    var task = new Task(() => Thread.Sleep(1000));
    Console.WriteLine($"状态:{task.Status}");  // Created

    // WaitingForActivation
    task.Start();
    Console.WriteLine($"状态:{task.Status}");  // WaitingToRun / Running

    // Running
    Thread.Sleep(100);
    Console.WriteLine($"状态:{task.Status}");  // Running

    // RanToCompletion
    task.Wait();
    Console.WriteLine($"状态:{task.Status}");  // RanToCompletion
}

3.2 Task 与 ThreadPool 的协作

Task.Run 的本质

// 源码简化版(CoreCLR)
public static Task Run(Action action)
{
    return Task.InternalStartNew(
        null,
        action,
        null,
        CancellationToken.None,
        TaskScheduler.Default,  // ← 关键:使用 ThreadPoolTaskScheduler
        TaskCreationOptions.DenyChildAttach,
        InternalTaskOptions.None
    );
}

完整流程可视化

flowchart TD A[用户代码调用<br/>Task.Run] –> B[Task.Run 方法] B –> C[调用 Task.InternalStartNew] C –> D[在托管堆分配 Task 对象<br/>约 200 bytes] D –> E[设置 Task 属性<br/>- _action = lambda<br/>- _scheduler = Default<br/>- _state = WaitingToRun] E –> F[TaskScheduler.Default<br/>ThreadPoolTaskScheduler] F –> G[调用 QueueTask] G –> H{当前线程是<br/>ThreadPool 线程?} H –>|是| I[放入本地队列<br/>LIFO 无锁<br/>性能最优] H –>|否| J[放入全局队列<br/>ConcurrentQueue<br/>低锁设计] I –> K[ThreadPool<br/>工作队列系统] J –> K K –> L{线程池有<br/>空闲线程?} L –>|是| M[从线程池取出线程<br/>复用现有线程<br/>无创建开销] L –>|否| N[Hill Climbing 算法<br/>评估是否需要新线程] N –> O{达到<br/>MaxThreads?} O –>|否| P[创建新线程<br/>OS 调用<br/>约 1 MB 内存] O –>|是| Q[任务排队等待<br/>直到有线程可用] M –> R[线程执行<br/>task.Execute] P –> R Q –> R R –> S[执行用户 lambda<br/>action.Invoke] S –> T{执行结果?} T –>|成功| U[Task.Status =<br/>RanToCompletion] T –>|异常| V[Task.Status =<br/>Faulted<br/>保存异常到 Task.Exception] T –>|取消| W[Task.Status =<br/>Canceled] U –> X[触发 continuation<br/>执行 await 后续代码] V –> X W –> X X –> Y[线程归还线程池<br/>等待复用<br/>不销毁] style A fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style D fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style I fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style J fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style M fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style P fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style Q fill:#ffe0b2,color:#e65100,stroke:#ef6c00,stroke-width:2px style Y fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px

关键流程说明

阶段 操作 成本 说明
1. 对象创建 分配 Task 对象 约 200 bytes 托管堆分配,GC 管理
2. 调度决策 选择队列(本地/全局) < 1 微秒 无锁本地队列最快
3. 线程获取 复用或创建线程 0 或 1 MB 优先复用,避免创建
4. 执行任务 运行用户代码 取决于任务 真正的工作负载
5. 状态更新 设置完成状态 < 1 微秒 触发 continuation
6. 线程回收 归还线程池 0 线程复用,无销毁成本

性能优化点

  1. 本地队列优先:在 ThreadPool 线程内提交的任务使用 LIFO 无锁本地队列,性能最优
  2. 线程复用:优先使用线程池现有线程,避免昂贵的线程创建(1 MB 内存 + OS 调用)
  3. 智能扩展:Hill Climbing 算法动态决定是否创建新线程,平衡吞吐量和资源消耗
  4. 零销毁成本:线程完成任务后归还线程池,不销毁,等待下次复用

Task 的内存开销

// Task 对象的大致结构(简化)
class Task
{
    private int _stateFlags;           // 4 bytes
    private object _action;            // 8 bytes(64 位)
    private object _result;            // 8 bytes
    private TaskScheduler _scheduler;  // 8 bytes
    private volatile int _id;          // 4 bytes
    private CancellationToken _token;  // ~16 bytes
    // ... 其他字段
    
    // 总计:约 100-200 bytes
}

对比

1 个 Thread:约 1 MB
1 个 Task:约 100-200 bytes

10000 个 Thread:约 10 GB
10000 个 Task:约 1-2 MB(内存节省 5000 倍!)

3.3 I/O 密集型 Task 不占用线程

核心区别

// CPU 密集型 Task(占用线程)
var task1 = Task.Run(() =>
{
    for (int i = 0; i < 1000000; i++)
    {
        var result = Math.Sqrt(i);  // CPU 计算
    }
});

// I/O 密集型 Task(不占用线程)
var task2 = Task.Run(async () =>
{
    await Task.Delay(1000);  // ← 这里不占用线程!
});

底层机制可视化

sequenceDiagram participant User as 用户代码 participant Task as Task 对象 participant Pool as ThreadPool participant Thread as 工作线程 participant OS as 操作系统<br/>定时器/IOCP participant IOThread as I/O 完成<br/>线程 Note over User,Thread: 第一阶段:启动异步操作 User->>Task: await Task.Delay(1000) Task->>Pool: 请求工作线程执行 Pool->>Thread: 分配线程 Thread->>Thread: 执行到 await 表达式 Note over Thread,OS: 第二阶段:发起 I/O 请求,释放线程 Thread->>OS: 创建 OS 定时器<br/>Windows: CreateThreadpoolTimer<br/>Linux: timerfd_create OS–>>Thread: 返回定时器句柄 Thread->>Task: 设置 continuation<br/>保存后续代码 Thread->>Pool: 线程归还线程池<br/>状态:可用 Note over Thread: 线程被释放!<br/>可以处理其他任务 Note over OS: 第三阶段:等待期(1000ms)<br/>无线程占用! OS->>OS: 定时器计时中…<br/>1000ms 倒计时 Note over User,IOThread: 1000ms 期间:<br/> 没有线程被占用<br/> 线程可以处理其他工作<br/> 内存开销:仅 Task 对象(约 200 bytes) Note over OS,IOThread: 第四阶段:I/O 完成通知 OS->>OS: 定时器到期! OS->>IOThread: 触发 I/O 完成通知<br/>Windows: IOCP<br/>Linux: epoll IOThread->>Task: 处理完成回调<br/>Task.Status = RanToCompletion Note over IOThread,Pool: 第五阶段:恢复执行 IOThread->>Pool: 请求线程执行 continuation Pool->>Thread: 分配线程<br/>可能是不同的线程! Thread->>User: 执行 await 后续代码 Note over User: await 后的代码继续执行 rect rgb(200, 230, 201) Note over Thread,OS: 关键洞察:<br/>线程在等待期间完全空闲<br/>1000ms 内线程数不会增加 end rect rgb(255, 224, 178) Note over OS,IOThread: I/O 完成端口(IOCP)<br/>专门的 I/O 线程池处理回调<br/>与工作线程池分离 end

CPU 密集型 vs I/O 密集型对比

sequenceDiagram participant User1 as CPU 任务 participant Thread1 as 工作线程 1 participant User2 as I/O 任务 participant Thread2 as 工作线程 2 participant OS as 操作系统 Note over User1,Thread1: CPU 密集型:线程持续占用 User1->>Thread1: Task.Run(() => Compute()) activate Thread1 Thread1->>Thread1: for i=0 to 1000000 Note over Thread1: 线程持续工作<br/>占用 CPU Thread1->>Thread1: Math.Sqrt(i) Note over Thread1: 无法释放<br/>一直到计算完成 Thread1->>Thread1: 继续计算… Thread1–>>User1: 计算完成 deactivate Thread1 Note over User2,OS: I/O 密集型:线程快速释放 User2->>Thread2: await Task.Delay(1000) activate Thread2 Thread2->>OS: 创建定时器 OS–>>Thread2: 定时器已创建 Thread2->>Thread2: 保存 continuation Thread2–>>User2: 线程立即释放 deactivate Thread2 Note over Thread2: 线程空闲<br/>可处理其他任务 Note over OS: 定时器计时中…<br/>1000ms OS->>OS: 定时器到期 OS->>User2: 触发回调 User2->>Thread2: 请求新线程 activate Thread2 Thread2->>User2: 执行后续代码 deactivate Thread2 rect rgb(255, 205, 210) Note over Thread1: CPU 任务:<br/>线程占用时间 = 计算时间<br/> 无法复用 end rect rgb(200, 230, 201) Note over Thread2,OS: I/O 任务:<br/>线程占用时间 ≈ 0<br/> 等待期间可复用 end

关键差异总结

特性 CPU 密集型(如计算) I/O 密集型(如网络、文件、延迟)
线程占用 持续占用 等待期间不占用
等待机制 CPU 执行循环 OS 级别的异步机制
可扩展性 受限于线程池大小 几乎无限(仅受内存限制)
10000 个任务 需要数百个线程 只需 10-20 个线程
性能瓶颈 CPU 核心数 网络带宽/磁盘 I/O
适用 API Task.Run(() => Compute()) await httpClient.GetAsync()

底层技术栈

平台 异步 I/O 机制 说明
Windows I/O Completion Ports (IOCP) 高效的异步 I/O 完成通知
Linux epoll / io_uring 高性能事件通知机制
macOS kqueue BSD 系统的事件通知

内存对比

10000 个 CPU 密集型 Task:
  - 需要约 100 个线程(假设长时间运行)
  - 内存:100 × 1 MB = 100 MB(线程栈)
  - 任务对象:10000 × 200 bytes = 2 MB
  - 总计:约 102 MB

10000 个 I/O 密集型 Task:
  - 需要约 10-20 个线程(快速释放)
  - 内存:20 × 1 MB = 20 MB(线程栈)
  - 任务对象:10000 × 200 bytes = 2 MB
  - 总计:约 22 MB

内存节省:80 MB(约 78%)

实验:验证 I/O 操作不占用线程

// 代码示例:IoTaskThreadDemo.cs
static async Task DemonstrateIoTaskThreadUsage()
{
    Console.WriteLine($"初始线程数:{ThreadPool.ThreadCount}");

    // 创建 10000 个 I/O 密集型 Task
    var tasks = new Task[10000];
    for (int i = 0; i < 10000; i++)
    {
        tasks[i] = Task.Run(async () =>
        {
            await Task.Delay(5000);  // I/O 操作
        });
    }

    await Task.Delay(100);  // 等待任务启动
    Console.WriteLine($"10000 个 I/O Task 运行中,线程数:{ThreadPool.ThreadCount}");
    // 预期输出:约 10-20 个(而不是 10000 个!)

    await Task.WhenAll(tasks);
}

3.4 Task 的调度器(TaskScheduler)

TaskScheduler 架构层次

flowchart TD A[Task API<br/>Task.Run / Task.Factory.StartNew] –> B{使用哪个<br/>TaskScheduler?} B –>|默认<br/>95% 场景| C[TaskScheduler.Default<br/>ThreadPoolTaskScheduler] B –>|UI 线程| D[TaskScheduler.FromCurrentSynchronizationContext<br/>SynchronizationContextTaskScheduler] B –>|自定义| E[自定义 TaskScheduler<br/>继承 TaskScheduler 抽象类] C –> F[ThreadPool 队列系统<br/>工作窃取 + 全局队列] F –> G[工作线程池执行<br/>多核并发] D –> H[UI 消息循环<br/>单线程执行] H –> I[UI 线程<br/>WPF/WinForms Dispatcher] E –> J{自定义调度逻辑} J –>|限流| K[LimitedConcurrencyLevelTaskScheduler<br/>最多 N 个并发] J –>|优先级| L[PriorityTaskScheduler<br/>按优先级调度] J –>|线程亲和性| M[ThreadAffinityTaskScheduler<br/>绑定特定线程] K –> N[内部队列 + 信号量<br/>控制并发数] L –> O[优先级队列<br/>高优先级先执行] M –> P[专用线程池<br/>线程本地化] style C fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style D fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style E fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style K fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style L fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style M fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px

调度器类型详解

调度器类型 使用场景 特点 示例代码
ThreadPoolTaskScheduler 默认,通用后台任务 高吞吐量,工作窃取 Task.Run(() => Work())
SynchronizationContextTaskScheduler UI 线程(WPF/WinForms) 单线程,顺序执行 Task.Run(() => Work()).ContinueWith(_ => UpdateUI(), TaskScheduler.FromCurrentSynchronizationContext())
LimitedConcurrencyLevelTaskScheduler 限流场景(如限制数据库连接) 控制最大并发数 new LimitedConcurrencyLevelTaskScheduler(4)
CurrentThreadTaskScheduler 同步执行(测试) 在当前线程立即执行 TaskScheduler.Current

默认调度器

// TaskScheduler.Default = ThreadPoolTaskScheduler
Task.Run(() => Console.WriteLine("使用默认调度器"));

// 等价于
Task.Factory.StartNew(
    () => Console.WriteLine("使用默认调度器"),
    CancellationToken.None,
    TaskCreationOptions.None,
    TaskScheduler.Default  // ← ThreadPoolTaskScheduler
);

调度流程可视化

sequenceDiagram participant User as 用户代码 participant Task as Task 对象 participant Scheduler as TaskScheduler participant Pool as ThreadPool participant Thread as 工作线程 User->>Task: Task.Factory.StartNew(action, scheduler) Task->>Task: 创建 Task 对象<br/>保存 action 和 scheduler Task->>Scheduler: scheduler.QueueTask(task) alt Default Scheduler (ThreadPoolTaskScheduler) Scheduler->>Pool: 调用 ThreadPool.QueueUserWorkItem Pool->>Thread: 分配线程执行 Thread->>Task: task.Execute() Task–>>User: 返回结果 else UI Scheduler (SynchronizationContextTaskScheduler) Scheduler->>Scheduler: SynchronizationContext.Post Scheduler->>Thread: 在 UI 线程执行 Thread->>Task: task.Execute() Task–>>User: 返回结果 else Custom Scheduler (自定义) Scheduler->>Scheduler: 自定义调度逻辑<br/>如限流、优先级 Scheduler->>Thread: 按自定义规则执行 Thread->>Task: task.Execute() Task–>>User: 返回结果 end

自定义调度器示例

示例 1:限制并发数的调度器

// 示例:限制并发数的调度器
var scheduler = new LimitedConcurrencyLevelTaskScheduler(maxDegreeOfParallelism: 4);

for (int i = 0; i < 100; i++)
{
    Task.Factory.StartNew(
        () => { /* 工作 */ },
        CancellationToken.None,
        TaskCreationOptions.None,
        scheduler  // 最多 4 个任务并发执行
    );
}

自定义调度器内部机制

flowchart TD A[100 个 Task 提交] –> B[LimitedConcurrencyLevelTaskScheduler] B –> C{当前运行数<br/>< 4?} C –>|是| D[立即调度执行<br/>运行数 +1] C –>|否| E[加入内部队列<br/>等待] D –> F[ThreadPool 执行任务] F –> G[任务完成] G –> H[运行数 -1] H –> I{内部队列<br/>有等待任务?} I –>|是| J[从队列取出一个任务<br/>开始执行] I –>|否| K[调度器空闲] J –> D E -.等待.-> I style B fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style C fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style D fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style E fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px

示例 2:UI 线程调度器

// WPF/WinForms:确保在 UI 线程更新界面
private async void Button_Click(object sender, EventArgs e)
{
    // 后台任务
    var result = await Task.Run(() => 
    {
        // 在 ThreadPool 线程执行(非 UI 线程)
        return ExpensiveComputation();
    });

    // 自动回到 UI 线程
    // await 后的代码在原 SynchronizationContext 执行
    this.TextBox.Text = result;  //  安全:在 UI 线程
}

// 或者显式指定 UI 调度器
await Task.Run(() => Work())
    .ContinueWith(
        t => UpdateUI(t.Result), 
        TaskScheduler.FromCurrentSynchronizationContext()  // UI 线程
    );

关键洞察

  1. TaskScheduler 是抽象层:解耦 Task 和执行机制,提供灵活性
  2. 默认调度器最优:95% 场景使用 ThreadPoolTaskScheduler 即可
  3. UI 调度器关键:确保 UI 更新在正确线程执行,避免跨线程异常
  4. 自定义调度器强大:可实现限流、优先级、亲和性等高级功能

3.5 Task vs Thread vs ThreadPool 总结

特性 Thread ThreadPool Task
创建成本 高(OS 调用) 低(复用) 低(内存分配)
内存占用 约 1 MB 共享线程 约 200 bytes
适用场景 长期后台任务 短期 CPU 任务 异步操作抽象
I/O 支持 阻塞 阻塞 不阻塞
灵活性 高(精确控制) 高(async/await)
推荐使用 很少 少(内部使用) 首选

小结

Task 是现代 .NET 并发编程的核心抽象。它通过 ThreadPool 复用线程(CPU 密集型)或完全不占用线程(I/O 密集型),实现了高效的并发。


Part 4: 三者关系图解

在理解了 Thread、ThreadPool 和 Task 的独立机制后,现在让我们从宏观视角看它们是如何协同工作的。

4.1 架构层次

从上到下的完整调用链:

flowchart TB App[“用户代码 Application<br/>━━━━━━━━━━━━━━━━━━━━<br/>async/await<br/>Task.Run<br/>Parallel”] TaskAPI[“Task API<br/>━━━━━━━━━━━━━━━━━━━━<br/>Task.Run()<br/>Task.WhenAll()<br/>等等”] Scheduler[“TaskScheduler<br/>━━━━━━━━━━━━━━━━━━━━<br/>Default = ThreadPoolTaskScheduler”] ThreadPool[“ThreadPool<br/>━━━━━━━━━━━━━━━━━━━━<br/>全局队列 + 本地队列 + 工作窃取”] Thread[“Thread<br/>━━━━━━━━━━━━━━━━━━━━<br/>OS 线程<br/>复用,不频繁创建/销毁”] OS[“操作系统<br/>Windows / Linux<br/>━━━━━━━━━━━━━━━━━━━━<br/>线程调度器 + 上下文切换”] App –> TaskAPI TaskAPI –> Scheduler Scheduler –> ThreadPool ThreadPool –> Thread Thread –> OS style App fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style TaskAPI fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style Scheduler fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style ThreadPool fill:#ffe0b2,color:#e65100,stroke:#ef6c00,stroke-width:2px style Thread fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style OS fill:#e1bee7,color:#6a1b9a,stroke:#7b1fa2,stroke-width:2px

关键理解

  • 用户代码:使用高层抽象(Task、async/await)
  • Task API:提供统一的异步接口
  • TaskScheduler:决定 Task 如何调度
  • ThreadPool:管理和复用线程
  • Thread:OS 级别的执行单元
  • 操作系统:线程调度和资源管理

4.2 Thread vs Task 执行流程对比

流程 1:new Thread() 的完整流程

flowchart TD A1[用户代码: new Thread] –> B1[创建 Thread 对象<br/>托管内存: 约 100 bytes] B1 –> C1{调用 Start?} C1 –>|否| D1[仅托管对象<br/>不消耗 OS 资源] C1 –>|是| E1[P/Invoke: 调用 OS API] E1 –> F1[CreateThread / pthread_create] F1 –> G1[分配内核对象<br/>1-8 MB 内存] G1 –> H1[线程加入调度队列] H1 –> I1[等待 OS 调度] I1 –> J1[开始执行用户代码] J1 –> K1[执行完成] K1 –> L1[线程销毁<br/>释放 OS 资源] style A1 fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style G1 fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style L1 fill:#ef9a9a,color:#c62828,stroke:#d32f2f,stroke-width:2px

流程 2:Task.Run() 的完整流程

flowchart TD A2[用户代码: Task.Run] –> B2[创建 Task 对象<br/>托管内存: 约 200 bytes] B2 –> C2[TaskScheduler.QueueTask] C2 –> D2[进入 ThreadPool 队列] D2 –> E2{线程池有空闲线程?} E2 –>|是| F2[从池中取出线程<br/>无需创建新线程] E2 –>|否| G2[检查是否达到 MaxThreads] G2 –>|未达到| H2[Hill Climbing 算法<br/>决定是否创建新线程] G2 –>|已达到| I2[任务排队等待] H2 –> J2[创建新线程<br/>类似 new Thread 流程] F2 –> K2[线程执行 Task] J2 –> K2 I2 –> K2 K2 –> L2[Task 完成] L2 –> M2[线程归还池中<br/>不销毁,等待复用] style A2 fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style M2 fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style F2 fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px

关键差异对比

特性 new Thread() Task.Run()
托管对象大小 约 100 bytes 约 200 bytes
是否创建 OS 线程 每次都创建 ️ 复用线程池
内存开销 1-8 MB(每个线程) 共享线程池
创建时间 约 50-200 微秒 约 1-5 微秒
销毁时间 约 50-200 微秒 0(线程不销毁)
适用场景 长期后台任务 大部分场景
资源回收 线程销毁,释放 OS 资源 线程归还池,等待复用
扩展性 差(线程数量有限) 好(复用机制)

核心区别

new Thread():
  1. 每次都创建新的 OS 线程
  2. 消耗 1-8 MB 内存
  3. 执行完成后销毁线程
  4. 无法复用

Task.Run():
  1. 优先从线程池复用线程
  2. 只在必要时创建新线程
  3. 执行完成后线程归还池中
  4. 高效复用

4.3 Task 的两种执行路径

路径 1:CPU 密集型(使用 ThreadPool 线程)

用户代码
    ↓
Task.Run(() => ComputePrimes())
    ↓
进入 ThreadPool 工作队列
    ↓
ThreadPool 线程执行 lambda
    ↓
占用线程,直到计算完成

路径 2:I/O 密集型(不占用线程)

用户代码
    ↓
await httpClient.GetAsync(url)
    ↓
发起异步 I/O 请求(OS 级别)
    ↓
线程立即释放(返回线程池)
    ↓
(等待网络响应,不占用线程)
    ↓
I/O 完成端口(IOCP)收到响应
    ↓
ThreadPool I/O 线程处理回调
    ↓
从线程池取一个线程继续执行后续代码

4.4 完整流程图:从 async/await 到 Thread

用户代码:
async Task ProcessAsync()
{
    await Task.Run(() => ComputePrimes());
}

流程:
1. 编译器生成状态机(AsyncMethodBuilder)
2. await Task.Run(...) 创建 Task 对象
3. Task.Run 调用 TaskScheduler.Default.QueueTask
4. ThreadPoolTaskScheduler 调用 ThreadPool.QueueUserWorkItem
5. ThreadPool 将任务放入工作队列
6. 某个线程池线程(Thread)从队列取出任务
7. 线程执行 ComputePrimes()
8. 完成后,Task 状态变为 RanToCompletion
9. 状态机恢复执行后续代码

实战对比

实战 1:创建 10000 个 Thread vs ThreadPool

// 代码示例:ThreadVsThreadPoolDemo.cs
// 见文章开头的实验

实战 2:ThreadPool 监控

// 代码示例:ThreadPoolMonitorDemo.cs
static void MonitorThreadPool()
{
    var timer = new System.Timers.Timer(1000);
    timer.Elapsed += (s, e) =>
    {
        ThreadPool.GetAvailableThreads(out int worker, out int io);
        ThreadPool.GetMinThreads(out int minWorker, out int minIo);
        ThreadPool.GetMaxThreads(out int maxWorker, out int maxIo);
        
        Console.WriteLine($"可用工作线程:{worker}/{maxWorker}");
        Console.WriteLine($"可用 I/O 线程:{io}/{maxIo}");
        Console.WriteLine($"当前线程数:{ThreadPool.ThreadCount}");
        Console.WriteLine("---");
    };
    timer.Start();
    
    // 提交一些任务
    for (int i = 0; i < 100; i++)
    {
        ThreadPool.QueueUserWorkItem(_ => Thread.Sleep(5000));
    }
    
    Console.ReadLine();
}

实战 3:.NET Framework vs .NET Core 性能对比

// 需要分别在两个项目中运行
// 项目 1:Threads(.NET 10)
// 项目 2:ThreadsFramework(.NET Framework 4.8)

static void BenchmarkThreadPool()
{
    const int taskCount = 100000;
    var sw = Stopwatch.StartNew();
    
    var countdown = new CountdownEvent(taskCount);
    for (int i = 0; i < taskCount; i++)
    {
        ThreadPool.QueueUserWorkItem(_ =>
        {
            // 简单工作
            var result = Math.Sqrt(42);
            countdown.Signal();
        });
    }
    
    countdown.Wait();
    sw.Stop();
    
    Console.WriteLine($"完成 {taskCount} 个任务:{sw.ElapsedMilliseconds}ms");
}

// 预期结果:
// .NET Framework 4.8:约 2000ms
// .NET Core / .NET 10:约 500ms(快 4 倍!)

常见误区澄清

误区 1:”Task 就是线程”

错误认知

var task = Task.Run(() => DoWork());
// 认为:创建了一个新线程

真相

// Task = 异步操作的抽象
// 可能使用 ThreadPool 线程(CPU 密集型)
// 可能不占用线程(I/O 密集型)

// CPU 密集型:使用线程池线程
var task1 = Task.Run(() => ComputePrimes());

// I/O 密集型:不占用线程
var task2 = Task.Run(async () => await httpClient.GetAsync(url));

误区 2:”Task.Run 会创建新线程”

错误认知

for (int i = 0; i < 10000; i++)
{
    Task.Run(() => DoWork());
}
// 认为:创建了 10000 个线程

真相

// Task.Run 只是将任务放入 ThreadPool 队列
// 实际使用的线程数 = ThreadPool.ThreadCount(约 10-100 个)
// 10000 个 Task 会排队等待执行

误区 3:”async 会创建新线程”

错误认知

async Task ProcessAsync()
{
    await Task.Delay(1000);
}
// 认为:async 关键字会创建线程

真相

// async 只是语法糖,生成状态机
// 不会创建线程
// await Task.Delay 使用 OS 定时器,不占用线程

最佳实践建议

1. 优先使用 Task 而不是 Thread

//  不推荐
new Thread(() => DoWork()).Start();

//  推荐
Task.Run(() => DoWork());

2. I/O 密集型使用 async/await

//  不推荐(阻塞线程)
var data = httpClient.GetStringAsync(url).Result;

//  推荐(不阻塞线程)
var data = await httpClient.GetStringAsync(url);

3. 不要耗尽 ThreadPool

//  不推荐(耗尽线程池)
for (int i = 0; i < 10000; i++)
{
    Task.Run(() => Thread.Sleep(10000));  // 长期占用
}

//  推荐(使用 SemaphoreSlim 限流)
var semaphore = new SemaphoreSlim(10);  // 最多 10 个并发
for (int i = 0; i < 10000; i++)
{
    await semaphore.WaitAsync();
    Task.Run(async () =>
    {
        try
        {
            await DoWorkAsync();
        }
        finally
        {
            semaphore.Release();
        }
    });
}

4. 监控 ThreadPool 健康度

// 定期检查
ThreadPool.GetAvailableThreads(out int worker, out int io);
if (worker < 10)
{
    Console.WriteLine("警告:线程池即将耗尽!");
}

总结

核心要点

  1. Thread

    • OS 级别的执行单元
    • 创建/销毁成本高(约 1 MB 内存)
    • 上下文切换成本高
    • 现代开发中很少直接使用
  2. ThreadPool

    • 线程复用机制
    • .NET Core 重构:工作窃取算法,性能提升显著
    • 动态调整线程数(Hill Climbing)
    • Task 的底层基础
  3. Task

    • 异步操作的抽象
    • CPU 密集型:使用 ThreadPool 线程
    • I/O 密集型:不占用线程
    • 现代 .NET 并发编程的首选

三者关系

Thread(基础)
    ↓
ThreadPool(优化)
    ↓
Task(抽象)
    ↓
async/await(语法糖)

下一章预告

在第三章《Task API 完全指南》中,我们将深入探讨:

  • Task 的创建、等待、组合 API
  • Task.WhenAll vs Task.WhenAny
  • ContinueWith 的陷阱
  • Task 的状态和异常处理

参考资料

官方文档

源码

深度文章


觉得有帮助?别忘了 ⭐ Star 本仓库!

文章摘自:https://www.cnblogs.com/diamondhusky/p/19874295