文章

C#多线程入门

C#多线程常用操作

C#多线程入门

C#多线程入门

前言

注:Python3.13去掉了Gil,已经拥有了真正的多线程

在使用Python写了几年 虚假的 多线程之后,想使用C#进行真正的多线程操作

问题的起因是遇到了高并发场景,同时需要控制线程对唯一文件IO进行操作,于是就有了这篇文章

如果你想深入了解更多的线程操作和同步机制,请点这里

后期应该会写一个实战,但不是现在 [Soon(TM)] 已经写完了

最简单的Task

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;
using System.Threading.Tasks;

class Program
{
  static void Main()
  {
    Task.Run(() => DoWork(1));
    Task.Run(() => DoWork(2));
  }
  
  static void DoWork(int id)
  {
    Console.WriteLine($"{id} is started");
    Thread.Sleep(1000);
    Console.WriteLine($"{id} is done");
  }
}

异步

常用于UI交互,处理长时任务时不会造成线程假死

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("开始下载...");
        await DownloadDataAsync();
        Console.WriteLine("下载完成");
    }

    static async Task DownloadDataAsync()
    {
        using (HttpClient client = new HttpClient())
        {
            string data = await client.GetStringAsync("https://example.com"); // 阻塞等待下载完成
            Console.WriteLine(data);
        }
    }
}

并发

强调任务交替执行,没有控制任务在哪个核心上执行,也不一定所有任务都会同时执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        List<Task> tasks = new List<Task>();

        for (int i = 1; i <= 5; i++)
        {
            int taskId = i;
            tasks.Add(Task.Run(() => DoWorkAsync(taskId)));
        }

        await Task.WhenAll(tasks);

        Console.WriteLine("所有任务已完成!");
    }

    static async Task DoWorkAsync(int id)
    {
        Console.WriteLine($"任务{id}开始");
        await Task.Delay(1000 * id);  // 模拟异步操作
        Console.WriteLine($"任务{id}完成");
    }
}

是不是感觉并发和并行在代码上很相似?你可以在这里找到区别

并行

充分利用多核CPU,确保多个任务能够同时执行

注:Parallel本身是基于线程池的执行工具 (基于ThreadPool调度管理线程) ,你可以在这里找到有关线程池的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("并行计算开始");

        // 使用 Parallel.For 并行计算
        Parallel.For(1, 11, i =>
        {
            int result = i * i;
            Console.WriteLine($"数字 {i} 的平方是: {result} (线程 {Task.CurrentId})");
        });

        Console.WriteLine("并行计算结束");
    }
}

是不是感觉并发和并行在代码上很相似?你可以在这里找到区别

并行、并发、异步的区别

这里把异步一起提到是因为很多人接触图形界面编程时更熟悉异步,而并不了解他

区别

并行

多个任务真正同时执行。并行通常需要多核处理器或多台计算机。每个任务运行在独立的核心或处理器上

并发

多个任务在同一时间段内交替执行。例如,仅单核处理但可以在不同任务之间快速切换,给人一种“同时进行”的感觉。

异步

异步是一种编程模型,指的是任务不需要等待其他任务完成就可以继续执行。异步任务通常会在等待某个操作(如 I/O 操作或网络请求)完成时释放控制权,让程序可以去做其他事情,等到操作完成后再继续


某种程度上说,异步是并发的一种实现方式,我认为原因如下:

  • 异步是一种并发模型
  • 异步本质上是一种非阻塞的任务处理
  • 并发强调交替进行,而并行则是同时进行,异步是通过非阻塞的方式实现并发的
  • 并发是一种任务管理思想,可以通过多线程、多进程、协程等方式来实现,而异步是其中一种方式

因此 并发 ≠ 并行,而 异步 ⊂ 并发

由此我们可以得到下面的表格 (理论上异步不应该在表格里):

方式类型实现方式特点
并行多进程在多个处理器或核心上运行多个进程并行依赖硬件
 多线程多核 CPU 上运行多个线程,每个线程执行不同任务 
并发多线程并发同一个进程中创建多个线程,交替执行并发是一种任务管理方式
 多进程并发通过多进程方式交替执行多个任务 
 协程在一个线程内非阻塞地切换任务 
异步事件异步使用事件触发任务执行异步是一种非阻塞的编程模型
 Async/Await非阻塞的 I/O 操作 
 Future/Promise表示即将完成的操作 

线程阻塞

线程阻塞适合那些比较强调顺序的操作,如等待用户输入, 定时任务, 等待另一个线程完成某项任务后才能继续执行等

他与线程锁的区别你可以在这里看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
using System;
using System.Threading;

class Program
{
    // 创建一个 ManualResetEvent 实例,初始状态为未触发(即阻塞状态)
    private static ManualResetEvent resetEvent = new ManualResetEvent(false);

    static void Main(string[] args)
    {
        Console.WriteLine("主线程启动。");

        // 启动一个新线程
        Thread workerThread = new Thread(Worker);
        workerThread.Start();

        Console.WriteLine("按任意键解除阻塞...");
        Console.ReadKey();

        // 解除阻塞,使工作线程继续
        resetEvent.Set();

        // 等待工作线程结束
        workerThread.Join();

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

    // 工作线程的执行方法
    static void Worker()
    {
        Console.WriteLine("工作线程启动,等待主线程解除阻塞...");

        // 阻塞,等待 ManualResetEvent 被触发
        resetEvent.WaitOne();

        Console.WriteLine("工作线程继续运行...");

        // 模拟一些工作
        Thread.Sleep(2000);

        Console.WriteLine("工作线程结束。");
    }
}

线程加锁

线程锁适合需要确保数据一致性的操作,如下面的文件IO操作,他与线程阻塞的区别你可以在这里看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    private static readonly object fileLock = new object();
    private static string filePath = "example.txt";

    static async Task Main(string[] args)
    {
        // 启动多个线程同时写入文件
        Task task1 = Task.Run(() => WriteToFile("线程1的数据"));
        Task task2 = Task.Run(() => WriteToFile("线程2的数据"));
        
        await Task.WhenAll(task1, task2);
        
        Console.WriteLine("文件写入完成。");
    }

    static void WriteToFile(string content)
    {
        lock (fileLock) // 加锁,确保只有一个线程能进入
        {
            using (StreamWriter writer = new StreamWriter(filePath, true)) // 追加模式
            {
                writer.WriteLine(content);
            }
        }
    }
}

线程锁和线程阻塞的区别

 线程锁线程阻塞
定义控制线程对资源的独占访问
(同时只能有一个线程在访问资源)
让线程暂停执行,等待条件满足
目的保护共享资源的完整性控制线程执行的顺序
触发条件线程试图访问已上锁的资源等待某个条件、资源、时间
影响同时只允许一个线程访问资源当前线程停止运行,释放CPU

线程池

目的: 管理和复用线程,减少频繁创建和销毁线程带来的开销

适用于 I/O 密集型任务、短时间大量的的并行任务、频繁执行的任务

在C#中,线程的创建、销毁和复用都是自动管理的, 这会引出下面的问题

线程池中的线程数量是有限的,默认会根据系统的 CPU 核心数自动调节, 你可以通过 ThreadPool.SetMinThreadsThreadPool.SetMaxThreads 设置最小和最大线程数

线程池中的线程由系统自动管理,开发者无法直接控制线程的启动和终止。这适合短时间任务,但不适合需要精确控制线程生命周期的场景

线程池主要设计用于处理短小的任务,不适合长时间运行的任务。长时间运行的任务可能会占用线程池中的线程,导致其他任务无法及时获得线程执行。

有三种方法可以使用线程池:

ThreadPool.QueueUserWorkItem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // 提交任务到线程池
        ThreadPool.QueueUserWorkItem(DoWork, "任务1");
        ThreadPool.QueueUserWorkItem(DoWork, "任务2");

        Console.WriteLine("任务已提交到线程池");
        Console.ReadLine(); // 防止程序立即退出
    }

    static void DoWork(object state)
    {
        string taskName = state as string;
        Console.WriteLine($"{taskName} 正在执行,线程ID: {Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(1000); // 模拟任务耗时
        Console.WriteLine($"{taskName} 执行完成");
    }
}

Task

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        // 使用 Task 提交任务到线程池
        Task task1 = Task.Run(() => DoWork("任务1"));
        Task task2 = Task.Run(() => DoWork("任务2"));

        await Task.WhenAll(task1, task2);

        Console.WriteLine("所有任务完成");
    }

    static void DoWork(string taskName)
    {
        Console.WriteLine($"{taskName} 正在执行,线程ID: {Thread.CurrentThread.ManagedThreadId}");
        Task.Delay(1000).Wait(); // 模拟任务耗时
        Console.WriteLine($"{taskName} 执行完成");
    }
}

Parallel

此方法在上文有提到过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        // 使用 Parallel.For 并行执行任务
        Parallel.For(0, 5, i =>
        {
            Console.WriteLine($"任务 {i} 正在执行,线程ID: {Thread.CurrentThread.ManagedThreadId}");
            Task.Delay(500).Wait(); // 模拟任务耗时
            Console.WriteLine($"任务 {i} 执行完成");
        });

        Console.WriteLine("所有任务完成");
    }
}

取消线程

在需要让某一线程停止工作时,由于可能会发生资源泄露、不一致(反正就是会出问题)一般不推荐销毁线程,解决方法是使用取消请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // 创建取消信号源
        CancellationTokenSource cts = new CancellationTokenSource();

        // 启动线程并传入取消令牌
        Thread thread = new Thread(() => DoWork(cts.Token));
        thread.Start();

        // 等待一段时间再取消线程
        Thread.Sleep(2000);
        Console.WriteLine("请求取消线程...");
        cts.Cancel();

        // 等待线程完成
        thread.Join();
        Console.WriteLine("线程已终止");
    }

    static void DoWork(CancellationToken token)
    {
        Console.WriteLine("线程开始执行...");
        for (int i = 0; i < 10; i++)
        {
            // 检查是否有取消请求
            if (token.IsCancellationRequested)
            {
                Console.WriteLine("取消请求已收到,线程即将退出...");
                return;
            }

            // 模拟工作
            Console.WriteLine($"正在处理数据 {i}...");
            Thread.Sleep(1000);
        }
        Console.WriteLine("线程正常结束");
    }
}

终止线程

在C#中,终止线程可能会导致不一致的状态,因此在.NET Core和.Net5之后已被弃用

由于已经弃用且不推荐使用,这里只说一下方法名

1
Thread.Abort()

如何使用并发和并行

在Web开发中,常会听到人们在强调高并发而非高并行

我们就以这个为例子来讲一下什么时候用并发,什么时候用并行

工作负载

工作负载常为I/O密集型CPU密集型任务

I/O密集型

大部分 Web 请求的操作包括数据库查询、文件读取、网络通信等,主要是I/O 操作而非复杂的计算

在处理这些操作时,处理的等待时间通常是瓶颈,而非计算能力

因此 Web 开发更关注如何处理大量的并发请求,以便在多个请求之间有效利用资源,而不是提高单个请求的执行速度

CPU密集型

科学计算、图像处理等任务需要充分利用CPU多核性能,这样可以利用多个核心运行多个任务来加速计算结果

I/O密集型任务常用并发,CPU密集型任务常用并行

由此可见,像Web服务这种对算力要求更低,但是涉及到很多请求的I/O密集型任务,使用并发是个更好的选择,在单个核心内能够同时处理更多的任务

在高并发环境中,资源的复用和高效调度是关键。现代 Web 服务器倾向于使用线程池、协程等机制来控制资源消耗。如果每个请求都创建一个线程,系统资源会迅速耗尽;相反,通过并发编程模型,可以让少量的线程处理大量的请求,提升服务器的资源使用效率


像科学计算这种需要大量算力的CPU密集型任务,使用并行是个更好的选择,可以充分利用多核CPU的全部性能

本文由作者按照 CC BY 4.0 进行授权