线程不是越多越快:C#.NET Thread 生命周期、同步与后台工作线程实战

简介

Thread 是 .NET 里直接创建和管理线程的底层 API。

命名空间:

csharp 复制代码
using System.Threading;

最简单的写法:

csharp 复制代码
Thread thread = new(() =>
{
    Console.WriteLine("工作线程正在执行");
});

thread.Start();

一句话概括:

text 复制代码
Thread 表示一条独立执行路径,可以直接控制线程的启动、名称、前后台属性、优先级和等待过程。

不过,现代 .NET 项目里并不需要到处 new Thread()

常见选择应该是:

text 复制代码
异步 I/O:async / await
普通后台计算:Task.Run
短小并发任务:Task / ThreadPool
长期独占、阻塞式工作线程:Thread

Thread 更底层,也更难管理。只有确实需要专用线程时,它的价值才真正体现出来。

线程到底是什么?

一个正在运行的进程里,可以有多条线程。

例如一个桌面程序可能同时存在:

text 复制代码
UI 线程
网络通信线程
日志写入线程
后台计算线程
运行时内部线程

这些线程共享同一个进程里的大部分资源:

  • 堆内存
  • 静态字段
  • 打开的文件
  • 数据库连接对象
  • 进程级配置

但每条线程也有自己的执行状态:

  • 调用栈
  • 当前执行位置
  • 寄存器上下文
  • 线程局部数据

现代 .NET 运行时中的托管线程,通常由操作系统线程承载。操作系统负责调度它们在哪个 CPU 核心上运行。

并发不等于并行

创建两个线程,只能说明有两条执行路径,不代表它们一定同时占用两个 CPU 核心。

text 复制代码
并发:多个任务在一段时间内交替推进
并行:多个任务在同一时刻真正同时执行

是否能并行,取决于:

  • CPU 核心数量
  • 操作系统调度
  • 线程是否正在阻塞
  • 进程当前负载
  • 运行环境限制

所以:

text 复制代码
线程多,不代表执行一定更快。

线程过多还会带来:

  • 线程栈内存开销
  • 上下文切换
  • 锁竞争
  • 缓存失效
  • 调度延迟

Thread、Task、ThreadPool 和 async 的区别

这几个概念经常混在一起。

技术 核心作用 是否直接代表线程
Thread 创建和控制专用线程
ThreadPool 复用一组工作线程 是线程池
Task 表示一个工作的完成状态 不一定
async/await 编排异步流程 不会自动创建线程

Task 不等于线程。

例如:

csharp 复制代码
await Task.Delay(1000);

等待期间不需要专门占着一条线程睡觉。

而:

csharp 复制代码
Thread.Sleep(1000);

会让当前线程真实阻塞一秒。

什么情况下使用 Thread?

适合:

  • 需要长期存在的专用工作线程
  • 必须调用阻塞式老 API
  • 需要设置线程优先级
  • 需要设置 STA / MTA 单元状态
  • 需要明确控制前台线程和后台线程
  • 与某些原生库、COM 组件或线程亲和资源集成
  • 线程必须长期维护自己的局部状态

不适合:

  • 普通 HTTP 请求
  • 数据库异步查询
  • 文件异步读写
  • 大量短小任务
  • ASP.NET Core 里给每个请求创建线程
  • 只是为了让方法看起来"异步"

Thread 常用成员

成员 作用
Start() 启动线程
Join() 阻塞当前线程,等待目标线程结束
Sleep() 阻塞当前线程一段时间
Interrupt() 中断处于等待、休眠或 Join 状态的线程
Yield() 提示调度器让出当前时间片
CurrentThread 获取当前线程
IsAlive 判断线程是否仍在运行
IsBackground 设置前台或后台线程
Name 设置线程名称
Priority 设置调度优先级提示
ManagedThreadId 获取托管线程 ID
ThreadState 查看线程状态

Demo 目标

下面通过多个示例讲清楚:

text 复制代码
创建线程
传递参数
获取结果
等待线程
前后台线程
协作取消
异常处理
共享数据同步
实现专用后台工作线程

创建控制台项目:

bash 复制代码
mkdir ThreadDemo
cd ThreadDemo

dotnet new console

创建并启动线程

csharp 复制代码
using System;
using System.Threading;

Console.WriteLine($"主线程 ID:{Environment.CurrentManagedThreadId}");

Thread worker = new(() =>
{
    Console.WriteLine($"工作线程 ID:{Environment.CurrentManagedThreadId}");
    Console.WriteLine("开始处理订单");
});

worker.Name = "OrderWorker";
worker.Start();

Console.WriteLine("主线程继续执行");

worker.Join();

Console.WriteLine("工作线程已经结束");

输出顺序可能类似:

text 复制代码
主线程 ID:1
主线程继续执行
工作线程 ID:4
开始处理订单
工作线程已经结束

工作线程和主线程会并发执行,因此中间几行的顺序不固定。

创建线程不等于启动线程

这段代码只创建线程对象:

csharp 复制代码
Thread worker = new(() => Console.WriteLine("执行任务"));

只有调用 Start() 后,线程才会开始运行:

csharp 复制代码
worker.Start();

同一个 Thread 实例只能启动一次。

错误示例:

csharp 复制代码
worker.Start();
worker.Join();
worker.Start();

第二次调用 Start() 会抛出 ThreadStateException

线程结束后如果还要重新执行,需要创建新的 Thread 实例。

使用 Lambda 传递参数

推荐直接通过 Lambda 捕获强类型参数:

csharp 复制代码
string orderNo = "SO202606140001";
decimal amount = 199.80m;

Thread worker = new(() =>
{
    Console.WriteLine($"处理订单:{orderNo}");
    Console.WriteLine($"订单金额:{amount}");
});

worker.Start();
worker.Join();

这种写法比 ParameterizedThreadStart 更清楚,因为参数类型在编译期就能检查。

需要注意闭包变量可能在子线程读取前被修改:

csharp 复制代码
int taskId = 1;

Thread worker = new(() => Console.WriteLine(taskId));

taskId = 2;
worker.Start();

输出通常是:

text 复制代码
2

Lambda 捕获的是变量,不是创建线程那一刻的值。

需要固定值时,可以先复制:

csharp 复制代码
int taskId = 1;
int capturedTaskId = taskId;

Thread worker = new(() => Console.WriteLine(capturedTaskId));

ParameterizedThreadStart

Thread 也支持一个 object? 参数:

csharp 复制代码
Thread worker = new(static state =>
{
    var args = (OrderArgs)state!;
    Console.WriteLine($"处理订单:{args.OrderNo},金额:{args.Amount}");
});

worker.Start(new OrderArgs("SO202606140002", 299.00m));
worker.Join();

public sealed record OrderArgs(string OrderNo, decimal Amount);

这种写法存在运行时类型转换。

普通业务代码优先使用强类型 Lambda。

Thread 如何返回结果?

Thread 没有 Thread<T>,也没有类似 Task<T> 的直接返回值。

通常需要通过共享变量接收结果:

csharp 复制代码
int result = 0;

Thread worker = new(() =>
{
    result = Enumerable.Range(1, 100).Sum();
});

worker.Start();
worker.Join();

Console.WriteLine(result);

这里必须先 Join(),再读取 result

Join() 不只是等待线程结束,也建立了必要的同步关系,确保目标线程结束前完成的写入对当前线程可见。

如果任务天然需要返回值、取消和异常传播,Task<T> 通常更合适:

csharp 复制代码
int result = await Task.Run(() => Enumerable.Range(1, 100).Sum());

Join 等待线程结束

Join() 会阻塞调用它的线程:

csharp 复制代码
Thread worker = new(() =>
{
    Thread.Sleep(1500);
    Console.WriteLine("任务完成");
});

worker.Start();

Console.WriteLine("开始等待");
worker.Join();
Console.WriteLine("等待结束");

也可以设置超时:

csharp 复制代码
bool completed = worker.Join(TimeSpan.FromMilliseconds(500));

if (!completed)
{
    Console.WriteLine("等待超时,线程仍在运行");
}

注意:

text 复制代码
Join 超时只是不再等待,不会停止目标线程。

前台线程和后台线程

直接创建的 Thread 默认是前台线程:

csharp 复制代码
worker.IsBackground = false;

只要还有前台线程没有结束,进程通常就不会退出。

后台线程:

csharp 复制代码
worker.IsBackground = true;

当所有前台线程都结束后,进程可以直接退出,不会等待后台线程完整收尾。

例如:

csharp 复制代码
Thread background = new(() =>
{
    Thread.Sleep(2000);
    Console.WriteLine("后台线程完成");
})
{
    IsBackground = true
};

background.Start();

Console.WriteLine("主线程结束");

后台线程完成 可能不会输出。

所以后台线程不能依赖这些收尾动作:

  • 最后一次日志写入
  • 文件刷新
  • 数据提交
  • 网络消息发送
  • 释放关键业务资源

重要任务应该提供明确的停止流程,并在退出前调用 Join()

Thread.Sleep 和 Task.Delay 的区别

csharp 复制代码
Thread.Sleep(1000);

会阻塞当前线程。

这一秒内,线程不能执行其他工作。

csharp 复制代码
await Task.Delay(1000);

表示异步等待,等待期间不会专门占用一条线程。

对比项 Thread.Sleep Task.Delay
是否阻塞线程
能否 await
常见场景 专用线程轮询、测试、简单退避 异步流程、服务端请求、UI

在异步方法里不要用:

csharp 复制代码
Thread.Sleep(1000);

应该使用:

csharp 复制代码
await Task.Delay(1000);

使用 CancellationToken 协作取消

.NET 不推荐强行杀死线程。

更合理的做法是发送取消信号,让线程自己结束。

csharp 复制代码
using var cts = new CancellationTokenSource();

Thread worker = new(() =>
{
    CancellationToken token = cts.Token;

    while (!token.IsCancellationRequested)
    {
        Console.WriteLine($"{DateTime.Now:HH:mm:ss} 检查新订单");

        if (token.WaitHandle.WaitOne(TimeSpan.FromMilliseconds(500)))
        {
            break;
        }
    }

    Console.WriteLine("工作线程正常退出");
});

worker.Start();

Thread.Sleep(1600);
cts.Cancel();

worker.Join();

这里没有直接使用:

csharp 复制代码
Thread.Sleep(500);

而是使用:

csharp 复制代码
token.WaitHandle.WaitOne(500)

好处是取消信号到达后,等待可以立刻结束,不必等满 500 毫秒。

Interrupt 中断等待状态

Interrupt() 可以中断处于这些状态的线程:

  • Sleep
  • Wait
  • Join

被中断的线程会抛出 ThreadInterruptedException

csharp 复制代码
Thread worker = new(() =>
{
    try
    {
        Console.WriteLine("线程进入等待");
        Thread.Sleep(Timeout.Infinite);
    }
    catch (ThreadInterruptedException)
    {
        Console.WriteLine("线程等待被中断");
    }
});

worker.Start();

Thread.Sleep(500);
worker.Interrupt();
worker.Join();

Interrupt() 不是安全的"终止线程"方法。

如果线程正在运行普通计算代码,中断不会立即终止计算。它主要影响线程进入等待状态的过程。

业务代码通常优先使用 CancellationToken

Thread.Abort 为什么不能用?

Thread.Abort() 试图在任意位置强行终止线程。

这会产生严重问题:

  • 锁可能没有释放
  • 数据可能只写了一半
  • 对象可能处于损坏状态
  • finally 和资源清理难以可靠推断

在现代 .NET 中,Thread.Abort() 不受支持,调用通常会抛出 PlatformNotSupportedException

Suspend()Resume() 也不应该用于现代代码。

线程停止应该采用:

text 复制代码
发送取消信号
线程主动退出循环
执行 finally 清理
调用 Join 等待结束

线程异常不会自动传回主线程

看这段代码:

csharp 复制代码
Thread worker = new(() =>
{
    throw new InvalidOperationException("订单处理失败");
});

worker.Start();
worker.Join();

工作线程里的异常不会因为 Join() 自动在主线程重新抛出。

未处理异常还可能直接终止整个进程。

需要在线程入口处捕获:

csharp 复制代码
Exception? workerException = null;

Thread worker = new(() =>
{
    try
    {
        throw new InvalidOperationException("订单处理失败");
    }
    catch (Exception ex)
    {
        workerException = ex;
    }
});

worker.Start();
worker.Join();

if (workerException is not null)
{
    Console.WriteLine(workerException.Message);
}

相比之下,Task 会把异常保存在任务中,await 时可以自然传播:

csharp 复制代码
await Task.Run(() => throw new InvalidOperationException("订单处理失败"));

这也是普通后台任务更适合使用 Task 的原因之一。

共享变量为什么会出错?

下面启动两个线程,每个线程执行十万次自增:

csharp 复制代码
int count = 0;

Thread t1 = new(() =>
{
    for (int i = 0; i < 100_000; i++)
    {
        count++;
    }
});

Thread t2 = new(() =>
{
    for (int i = 0; i < 100_000; i++)
    {
        count++;
    }
});

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

Console.WriteLine(count);

预期结果是:

text 复制代码
200000

实际结果可能更小。

因为 count++ 不是一个不可分割的动作,它包含:

text 复制代码
读取 count
加一
写回 count

两个线程可能同时读到旧值,然后互相覆盖。

使用 Interlocked 修复计数器

简单原子计数可以使用:

csharp 复制代码
Interlocked.Increment(ref count);

完整写法:

csharp 复制代码
int count = 0;

Thread t1 = new(Increment);
Thread t2 = new(Increment);

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

Console.WriteLine(count);

void Increment()
{
    for (int i = 0; i < 100_000; i++)
    {
        Interlocked.Increment(ref count);
    }
}

使用 lock 保护复合操作

如果临界区不只是一个简单自增,就应该使用锁:

csharp 复制代码
object gate = new();
decimal balance = 1000m;

void Withdraw(decimal amount)
{
    lock (gate)
    {
        if (balance < amount)
        {
            return;
        }

        balance -= amount;
    }
}

锁对象建议满足这些条件:

  • 私有
  • 只读引用
  • 专门用于同步

类字段通常写成:

csharp 复制代码
private readonly object _gate = new();

不要锁这些对象:

csharp 复制代码
lock (this)
lock (typeof(SomeType))
lock ("固定字符串")

它们可能被外部代码共享,容易产生意料之外的锁竞争或死锁。

volatile 能代替 lock 吗?

不能。

volatile 主要影响内存读写的可见性和顺序,不会让复合操作自动变成原子操作。

下面依然不安全:

csharp 复制代码
private volatile int _count;

_count++;

简单停止标记可以考虑 volatile,复杂状态更新仍然需要 lockInterlocked 或其他同步原语。

线程名称和优先级

设置名称有助于日志和调试:

csharp 复制代码
Thread worker = new(ProcessOrders)
{
    Name = "OrderWorker"
};

当前线程名称:

csharp 复制代码
Console.WriteLine(Thread.CurrentThread.Name);

线程名称通常只能设置一次。重复设置可能抛出 InvalidOperationException

优先级:

csharp 复制代码
worker.Priority = ThreadPriority.AboveNormal;

可选值:

text 复制代码
Lowest
BelowNormal
Normal
AboveNormal
Highest

优先级只是给操作系统调度器的提示,不保证高优先级线程一定先执行,也不应该用它实现业务顺序。

大多数项目保持默认 Normal 即可。

ThreadState 只能用于观察

可以读取:

csharp 复制代码
Console.WriteLine(worker.ThreadState);

常见状态包括:

  • Unstarted
  • Running
  • WaitSleepJoin
  • Stopped
  • Background

ThreadState 是一个瞬时快照。

读取完之后,线程状态可能马上变化,因此不适合用它编写关键同步逻辑。

需要协调线程时,应该使用:

  • Join
  • CancellationToken
  • ManualResetEventSlim
  • AutoResetEvent
  • lock
  • SemaphoreSlim

不要把 async Lambda 直接交给 Thread

下面的写法很危险:

csharp 复制代码
Thread worker = new(async () =>
{
    await Task.Delay(1000);
    throw new InvalidOperationException("处理失败");
});

ThreadStart 要求返回 void

所以这个异步 Lambda 会变成 async void

text 复制代码
Thread 只负责执行到第一个未完成的 await
await 后续逻辑不再由这条专用线程保证
异常也无法通过 Task 观察

需要异步流程时,直接使用:

csharp 复制代码
Task worker = Task.Run(async () =>
{
    await Task.Delay(1000);
});

专用 Thread 更适合同步、阻塞式循环。

实战 Demo:专用订单工作线程

下面实现一个完整的后台工作线程。

它负责:

text 复制代码
接收订单任务
按顺序处理
捕获单个任务异常
停止接收新任务
处理完队列后退出
主线程等待它正常收尾

使用 BlockingCollection<T> 作为阻塞队列:

csharp 复制代码
using System.Collections.Concurrent;

public sealed class OrderWorker : IDisposable
{
    private readonly BlockingCollection<OrderJob> _queue = new();
    private readonly Thread _thread;
    private bool _started;
    private bool _stopped;
    private bool _disposed;

    public OrderWorker()
    {
        _thread = new Thread(Run)
        {
            Name = "OrderWorker",
            IsBackground = true
        };
    }

    public void Start()
    {
        ThrowIfDisposed();

        if (_started)
        {
            throw new InvalidOperationException("工作线程不能重复启动");
        }

        if (_stopped)
        {
            throw new InvalidOperationException("已经停止的工作线程不能重新启动");
        }

        _started = true;
        _thread.Start();
    }

    public void Enqueue(OrderJob job)
    {
        ThrowIfDisposed();

        if (_stopped || _queue.IsAddingCompleted)
        {
            throw new InvalidOperationException("工作线程已经停止接收任务");
        }

        _queue.Add(job);
    }

    public void Stop()
    {
        if (_disposed || _stopped)
        {
            return;
        }

        _stopped = true;
        _queue.CompleteAdding();

        if (_started)
        {
            _thread.Join();
        }
    }

    private void Run()
    {
        Console.WriteLine($"工作线程启动,ID:{Environment.CurrentManagedThreadId}");

        foreach (OrderJob job in _queue.GetConsumingEnumerable())
        {
            try
            {
                Process(job);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"订单 {job.OrderNo} 处理失败:{ex.Message}");
            }
        }

        Console.WriteLine("订单队列处理完成,工作线程退出");
    }

    private static void Process(OrderJob job)
    {
        Console.WriteLine($"开始处理订单:{job.OrderNo},金额:{job.Amount}");

        Thread.Sleep(300);

        if (job.Amount < 0)
        {
            throw new InvalidOperationException("订单金额不能小于 0");
        }

        Console.WriteLine($"订单处理完成:{job.OrderNo}");
    }

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        Stop();
        _queue.Dispose();
        _disposed = true;
    }

    private void ThrowIfDisposed()
    {
        if (_disposed)
        {
            throw new ObjectDisposedException(nameof(OrderWorker));
        }
    }
}

public sealed record OrderJob(string OrderNo, decimal Amount);

调用代码:

csharp 复制代码
using var worker = new OrderWorker();

worker.Start();

worker.Enqueue(new OrderJob("SO001", 99.00m));
worker.Enqueue(new OrderJob("SO002", 268.00m));
worker.Enqueue(new OrderJob("SO003", -1.00m));
worker.Enqueue(new OrderJob("SO004", 520.00m));

worker.Stop();

输出类似:

text 复制代码
工作线程启动,ID:4
开始处理订单:SO001,金额:99.00
订单处理完成:SO001
开始处理订单:SO002,金额:268.00
订单处理完成:SO002
开始处理订单:SO003,金额:-1.00
订单 SO003 处理失败:订单金额不能小于 0
开始处理订单:SO004,金额:520.00
订单处理完成:SO004
订单队列处理完成,工作线程退出

这个 Demo 展示了专用线程比较合理的使用方式:

  • 线程长期存在
  • 工作是同步阻塞式的
  • 队列负责等待和唤醒
  • 停止过程明确
  • 退出前调用 Join()
  • 单个任务异常不会打崩整个工作线程

示例默认由一个组件统一负责 Start()Stop()Dispose()。如果多个线程可能同时控制生命周期,还需要用锁保护 _started_stopped_disposed 状态。

如果任务处理本身是异步 I/O,更适合改用:

text 复制代码
Channel<T> + BackgroundService + async/await

OrderWorker 还能怎么改进?

上面的 Demo 为了突出线程模型,保持了较简单的结构。

实际项目还可以增加:

  • 有界队列,避免任务无限堆积
  • 取消超时
  • 重试策略
  • 死信队列
  • 处理耗时指标
  • 更完善的日志
  • 启动和停止状态检查
  • 多调用方并发启停保护

有界队列示例:

csharp 复制代码
private readonly BlockingCollection<OrderJob> _queue = new(boundedCapacity: 1000);

队列满时,Add() 会阻塞生产者,形成背压。

STA 线程是什么?

某些 Windows 技术要求线程运行在单线程单元中,例如部分:

  • COM 组件
  • 剪贴板操作
  • OLE
  • WPF / WinForms 相关能力

可以在线程启动前设置:

csharp 复制代码
Thread thread = new(() =>
{
    // Windows STA 工作
});

thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();

这属于平台相关能力,不适合跨平台通用业务逻辑。

常见错误

1. 每个请求创建一条线程

错误思路:

csharp 复制代码
app.MapGet("/orders", () =>
{
    new Thread(ProcessOrders).Start();
    return "ok";
});

请求一多,就会创建大量线程,带来严重调度和内存压力。

ASP.NET Core 中应该优先使用异步 API、后台队列和托管服务。

2. 用 Sleep 等待异步 I/O

错误:

csharp 复制代码
while (!completed)
{
    Thread.Sleep(100);
}

这会浪费线程,还可能出现数据可见性问题。

应该使用任务、事件、信号量或异步 API 等明确的通知机制。

3. 依赖后台线程完成关键工作

后台线程可能随进程退出直接终止。

关键数据必须有明确的停止、刷新和等待流程。

4. 忽略线程入口异常

每条手工线程都应该有清楚的异常边界。

线程入口处没有捕获的异常可能终止整个进程。

5. 创建太多专用线程

Thread 不会像线程池一样自动复用。

大量短任务应交给:

  • Task.Run
  • ThreadPool
  • Parallel
  • PLINQ

6. 用 Priority 控制业务顺序

线程优先级不是任务排序工具。

需要先后顺序时,应该使用队列、锁、信号量或任务依赖。

Thread 的合理使用边界

可以用 Thread

text 复制代码
长期阻塞的专用循环
需要固定线程属性
需要 STA
原生组件要求固定线程
维护传统同步线程模型

优先使用 Task

text 复制代码
需要返回结果
需要自然传播异常
需要组合多个任务
短期 CPU 计算
大多数后台工作

优先使用 async/await

text 复制代码
HTTP 调用
数据库访问
文件 I/O
消息队列异步客户端
定时等待

总结

Thread 是 .NET 并发体系里最直接的线程控制工具。

它提供了:

  • 独立线程生命周期
  • 前台和后台线程控制
  • 名称和优先级设置
  • Join 阻塞等待
  • Interrupt 中断等待状态
  • STA / MTA 单元配置

但它没有 Task 那些更现代的能力:

  • 没有直接返回值
  • 没有自然的异常传播
  • 没有任务组合
  • 没有自动线程复用
  • 没有内置的协作取消模型

实战里的选择可以归纳为:

text 复制代码
真正需要专用线程时使用 Thread。
普通并发任务交给 Task 和线程池。
异步 I/O 直接使用 async/await。

掌握 Thread 的重点不是创建更多线程,而是理解线程的生命周期、共享状态、退出方式和使用边界。

相关推荐
唐青枫1 天前
别只会反射:C#.NET Emit 动态生成代码实战详解
c#·.net
Caco_D1 天前
一行代码抓遍全网 20 个热榜!Aneiang.Pa 4.0 发布 — 极简 .NET 爬虫库
爬虫·.net
咕白m6251 天前
.NET 环境下 Word 超链接批量提取方案
c#·.net
用户91721561902111 天前
C# 通信协议增量解析:用状态机处理半包和粘包
c#
小码编匠2 天前
C# 工控上位机必备:数据转换工具类与十个核心模块
后端·c#·.net
唐青枫4 天前
别再乱用 StartNew:C#.NET TaskFactory 任务调度实战详解
c#·.net
Artech4 天前
[MAF预定义的AIContextProvider-03]ChatHistoryMemoryProvider——赋予Agent从经验中学习的能力
ai·c#·agent·memory·maf
Scout-leaf6 天前
C#摸鱼实录——IoC与DI案例详解
c#