c#多线程相关整理01——Thread篇

2024-05-09

说明

无聊整理一下线程相关操作——Thread篇

什么是多线程

参考:C#高级--多线程详解_c# 多线程-CSDN博客

进程:当一个程序开始运行时,他就是一个进程。进程包括运行中的程序和程序所使用的内存和系统资源,进程是由多个线程所组成的

线程:线程是程序中的一个执行流,每个线程都有自己的专有寄存器(栈指针、程序计数器等),都可以执行同样的函数

多线程:多线程是指程序中包含多个执行流。一个程序可以同时运行多个不同的线程来执行任务

以下参考:From AI

进程:进程是指正在运行的程序的实例。每个进程都有自己的内存空间、系统资源和执行线程

线程:线程是指程序中的一个执行路径。当一个程序启动时,操作系统会为该程序创建一个主线程,用于执行程序的主要任务

多线程:多线程是指程序中的多个执行路径

通俗来讲,当我们运行一个程序,这个运行中的程序就是进程。进程默认创建一个主线程,基本所有的逻辑都在主线程上执行。我们可以主动创建线程用于执行任务,多个这样的线程就是所谓的多线程。如下图,不知道这样会不会更好理解一点

image-20240403160209683

多线程的优点

使用多线程可以提高CPU的利用率,在某个时间点同时执行多个(子)任务,从而减少(总)任务耗时,提高效率。

多线程的缺点

多线程会占用计算机资源(占内存),消耗CPU资源(占cpu),而且多线程还会造成资源共享问题(线程不安全),调试困难(技巧:调试逻辑的时候可以把多线程改成单线程调试,逻辑通过后再将单线程改造成多线程即可)

多线程怎么实现

我们可以使用Thread、ThreadPool、Task、TaskFactory、Parallel并行来实现多线程

Thread常见操作

篇幅太大,本篇先整理一下Thread常见操作,难免有疏漏,欢迎补充指正

创建与开始

new、start这俩经常同时出现,创建一个线程一定要主动调用开始,不然这个线程就没有意义了(不会执行)。而且如果在子线程执行完成之前,主线程已执行完毕,那么子线程也不会完整执行(可能只执行了一部分)

如图,thread2没有调用start方法,所以不会执行;thread4执行的时候,主线程已经执行完成,所以只执行了一部分;thread1、thread3正常执行;

image-20240407114124518

指定线程名称
start传入参数

start传入参数可以使用ParameterizedThreadStart、匿名函数(推荐)

参考:C# Thread启动线程时传递参数_c# thread 传参-CSDN博客

image-20240408120318329

前台线程和后台线程

前台线程在程序执行过程中具有较高的优先级,操作系统会确保所有前台线程在程序退出之前完成执行。如果程序中的所有前台线程都执行完毕,程序将自动退出

后台线程优先级低于前台线程。当程序中的所有前台线程都执行完毕时,操作系统会立即终止所有后台线程,而不管它们是否已经完成执行。因此,后台线程通常用于执行一些不影响程序退出的任务,如数据处理、文件读写等

默认为前台线程,通过设置IsBackground为True,变成后台线程

等待

等待可分为Sleep和SpinWait,作用是将当前线程挂起一定时长。Sleep会放弃CPU使用权,等待结束后重新竞争CPU。而SpinWait不会放弃CPU使用权,占用时间片,等待结束后立即执行,不用重新竞争CPU

线程等待时,状态会变成WaitSleepJoin状态。主线程中执行则主线程等待,线程中执行则线程等待,不影响主线程。

后续还会介绍延迟等待——await Task.Delay(1000);

唤醒与等待(对象锁)

Monitor.Pulse:唤醒等待某个对象锁的线程

Monitor.PulseAll:唤醒等待对象锁的全部线程

Monitor.Wait:使当前线程等待某个对象锁

唤醒与等待一般都会成对出现,在一个线程中,当另一个线程等待某个条件满足时,可以使用唤醒机制来通知等待的线程

如下图的执行顺序,尽管我们在执行主线程逻辑(yyy)前已经将线程thread开始执行,并强制让主线程休眠5000ms,但是线程thread依旧会进入等待(lockObj对象锁)。过了5000ms后,主线程开始执行(yyy)逻辑,并将条件满足(isTrue为True)同时唤醒等待lockObj对象锁的线程,此时thread线程才会往下执行,由于thread.Join()的原因,主线程会一直等待线程执行完成后,主线程才往下执行。

image-20240403171145071

阻塞

线程阻塞是由于某些原因(如等待资源、等待 I/O 操作完成、等待其他线程完成任务等)而暂停该线程执行的状态。当线程阻塞时,它不会占用 CPU 资源,操作系统会将其从运行队列中移除,并将其放入阻塞队列中。当阻塞的原因解除后,线程会重新进入运行队列,等待操作系统调度执行。

C#中实现线程阻塞的几种常见方法

  • Thread.Sleep();
  • Monitor.Enter(lock)和Monitor.Exit(lock)
  • mutex.WaitOne()和mutex.ReleaseMutex()
  • semaphore.WaitOne()和semaphore.Release()

线程通过调用Sleep阻塞当前线程

image-20240403173805990

Monitor类提供了一种同步机制,可以使用Enter和Exit方法来实现线程的阻塞和唤醒。Monitor.Enter用于获取对象的监视器锁,用于确保在同一时间只有一个线程可以访问共享资源。当一个线程获取了对象的监视器锁后,其他线程必须等待该锁被释放才能访问该对象。如图,我们可以看到多个线程只有一个线程处于运行中状态

image-20240403180455518

Mutex类也提供了一种同步机制,可以使用WaitOne和ReleaseMutex方法来实现线程的阻塞和唤醒。Mutex.WaitOne 用于等待 Mutex 对象被释放。确保在同一时间只有一个线程可以访问共享资源。当一个线程获取了 Mutex 对象后,其他线程必须等待该对象被释放才能访问该资源。如图,我们可以看到多个线程只有一个线程处于运行中状态

image-20240407103602809

Semaphore类也是一种用于同步的类,可以通过WaitOne和Release方法来实现线程的阻塞和释放。Semaphore.WaitOne() 用于等待 Semaphore 对象被释放。控制对共享资源的访问。当一个线程获取了 Semaphore 对象后,其他线程必须等待该对象被释放才能访问该资源。如图,我们可以看到多个线程只有一个线程处于运行中状态

image-20240407105404666

阻塞等待

join也是阻塞的一种,join用于“阻塞”当前线程,等待被调用的线程执行完毕。join允许指定超时时间。

如图,thread1调用join后,主线程会等待thread1线程完成后才继续往下执行。(为啥主线程还是运行中状态???预期是WaitSleepJoin状态,子线程可达到预期)

image-20240407160341436

中断

Interrupt可用于中断线程,但只可以中断状态为WaitSleepJoin的线程,中断成功会抛出异常。

如图,当我们对thread1、thread2调用Interrupt尝试中断线程时,thread1并没有中断成功,由于thread2处于WaitSleepJoin状态,所以thread2可以中断成功

image-20240407163829904

终止

Abort用于终止当前线程的执行。在 .NET Core 和 .NET 5 及以后的版本中,该方法不再受支持并抛出 PlatformNotSupportedException 异常,不推荐使用,所以这里就不展开讲了,重点讲一下不使用Abort如何终止线程。

我们可以是用其他方式终止线程

  1. 使用 CancellationToken 来取消线程任务。
  2. 使用 Thread.Interrupt 方法中断线程,注意只能中断WaitSleepJoin状态的线程还需要手动捕获异常——ThreadInterruptedException。(参考上方的Interrupt)
  3. 使用 Taskasync/await 来管理异步操作的取消和异常处理——Task章节补充。

如图,我们使用 CancellationToken 来取消线程任务。可以看到,thread线程终止成功,仅执行了一部分就没有往下执行了

image-20240407175539694

线程死亡

线程死亡是指线程已经完成了其任务,并且已经被操作系统回收,我们可以通过IsAlive来判断线程是否存活。线程死亡后,线程的资源会被操作系统回收,因此不能再次使用该线程。以下操作会造成线程死亡

  • 自然死亡,线程任务完成,自动结束。
  • 线程被终止/中断——Abort/Interrupt
  • 线程被取消——CancellationToken

thread.join(time)——注意线程超时并不会造成线程死亡。若主线程有足够的时间等待子线程(join超时后主线程仍未结束),那么子线程的逻辑依旧会完整执行

如图,thread1被取消、thread2被中断线程状态变为Stoped状态,此时线程未存活;thread3调用join等待超时,但线程状态仍然是Running且存活;最后thread3、thread4线程执行完成后,线程变为不存活;

image-20240408114646360

线程锁是一种同步机制,用于确保多个线程在访问共享资源时不会发生冲突。因为线程需要等待锁被释放,线程锁会影响性能,在使用线程锁时,应尽量减少锁定的代码块的大小,以提高性能。此外,还可以使用其他同步机制,如 Monitor、Mutex、Semaphore 等,来实现线程同步。

image-20240408152352961

死锁

线程死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法继续执行下去。

如图,线程1拥有锁A的同时去等待获取锁B才能继续往下执行,而线程2拥有锁B的同时去等待获取锁A才能往下继续执行,这时局面就僵持住了,导致两个线程都没法继续往下执行。

image-20240408160206650

 

产生死锁的四个必要条件

这部分内容通俗易懂,给原作者点赞。参考:多线程——死锁详解_多线程事务死锁-CSDN博客

发生死锁,必须要具备着四个条件,当同时具备时,才会出现死锁。

  1. 互斥使用。⼀个资源只能被⼀个线程占有,当这个资源被占⽤之后其他线程就只能等待——线程1拿到了锁,线程2就得等着(锁的基本特性)
  2. 不可抢占。当⼀个线程不主动释放资源时,此资源⼀直被拥有线程占有,其他线程不能得到此资源——线程1拿到锁之后,必须是线程1主动释放。线程2不能强行把锁获取。
  3. *请求和保持。 线程已经拥有了⼀个资源之后,又尝试请求新的资源——线程1拿到锁A之后,又尝试获取锁B,A这把锁还是保持的(不会因为尝试获取锁B就给锁A释放了)。
  4. *循环等待。线程1尝试获取到锁A和锁B,线程2尝试获取到锁B和锁A——线程1在尝试获取锁B的时候需要等待线程2释放锁B;同时线程2在尝试获取锁A的时候需要等待线程1释放锁A。

如图,演示死锁。程序执行了6分钟都没有执行完完整逻辑,如果不是加了Join(200 * 1000)限定等待时间,那么没有外力的作用影响下,程序永远都不会结束也永远不会往下执行

image-20240409153616459

怎样避免死锁的发生呢?我们都知道死锁的发生必须同时具备四个条件,缺一不可。所以我们只要不满足一个条件那么就能有效避免死锁的发生了。

  1. 破坏互斥条件——将互斥资源改造成共享资源。
  2. 破坏不剥夺条件——当(长时间)无法获取某个资源时,需要释放该线程所有占有资源,并做相应补偿操作(提醒、重试等)。
  3. 破坏请求和保持条件——采用静态分配法,运行前一次性申请全部资源,全部获取到资源后才往下执行。在C#中,lock关键字会自动破坏保持和请求条件,因为它会在锁定锁对象之前等待,直到锁对象可用。使用lock时需要小心处理锁的顺序。如果锁的顺序不正确,可能会导致死锁
  4. 破坏循环等待条件——采用顺序资源分配法。即给相同类型的互斥资源进行从小到大编号,当要获取编号大的资源时,必要先获取到排在他前面的所有同类资源

更多请参考:死锁的处理策略—预防死锁、避免死锁、检测和解除死锁_死锁预防和死锁避免-CSDN博客

常用的死锁避免方法

加锁时序

避免死锁最有效的方式就是避免循环加锁。如果真的需要循环加锁,那么我们可以使用加锁时序的方式来避免死锁。简单来讲就是给锁资源维护一个排序——锁ABCD...,当获取某个锁资源时必须要同时获取前面的所有锁才有可能获取到指定的锁资源。如线程2想要获取锁D,那么线程2要先获取锁A、锁B、锁C再获取锁D。当然了,这对性能肯定有非常大的影响的

如图,我们可以发现程序正常执行。每个线程都是按同类资源顺序先获取锁A再获取锁B,执行逻辑后先释放锁B再释放锁A,每个线程都能完整执行所有逻辑,因为每个线程开始前都会先去竞争锁A,获取到锁A才往下执行,自然就不会出现循环等待的情况了

image-20240409161909645

加锁时限

在C#中,可以使用Monitor类的TryEnter方法来设置加锁时限,从而避免死锁。TryEnter不会抛出异常,我们可以根据返回值进行下一步补偿操作。返回true,说明获取到锁资源,返回false,说明在指定等待时间内没有获取到锁资源。

如图、由于未获取到锁资源,有些线程没有完整执行。加锁时限可以有效避免发生死锁,我们可以根据返回值进行下一步补偿操作

image-20240409163736534

volatile关键词

参考:[C#.NET 拾遗补漏]10:理解 volatile 关键字 - 精致码农 - 博客园 (cnblogs.com)

Release模式下,编译器会优化我们的代码,减少不必要的重复运算。

在 Release 模式下,编译器读取 x = 5 后紧接着读取 y = x + 10,在单线程思维模式下,编译器会认为 y 的值始终都是 15。所以编译器会把 y = x + 10 优化为 y = 15,避免每次读取 y 都执行一次 x + 5的操作。但 x 字段的值可能在运行时被其它的线程修改,但是我们拿到的 y 值并不是修改后的值,y 的值永远都是 15

在单线程中一般不会有问题,但如果在多线程中,就会出现这种情况:进程将某个变量number分配给两个线程,thread2线程修改number值,但是thread1线程并没有获取到修改后的number值,如下

如图,thread0、thread1线程并没有按照预期执行成功——thread2线程修改了number的值,但是thread0、thread1线程未能获取到thread2线程修改number后的值,导致一直处于while循环,无法往下操作——while(number == 0)始终成立

image-20240509103334814

为了解决这种情况,我们可以引入volatile关键词修饰类的字段,目的是告诉编译器该字段的值可能会被多个独立的线程改变,不要对该字段的访问进行优化

如图,thread0、thread1线程均成功执行——这俩线程获取到修改后的值,跳出while循环,继续往下执行——while(number==0)不成立

image-20240509103701058

使用volatile需要注意以下几点。

volatile不能用来做线程同步

volatile 不能用来做线程同步(多次修改),它的主要作用是为了让多个线程之间能看到被修改过后最新的值。

如下,volatile 不能用来做线程同步(多次修改),无法代替锁

image-20240509104616057

volatile 仅支持类或结构的字段

如下,volatile 仅支持类或结构的字段(自然不能用var声明),不支持局部变量

image-20240509105104298

volatile 支持常见简单类型、引用类型

volatile 支持常见简单类型、引用类型,但是不支持long和double类型。这些类型没有一一尝试,粗略尝试几个,见仁见智。更多请参考:volatile - C# 参考 - C# | Microsoft Learn

image-20240509105647129

volatile 仅在Release环境下起作用

volatile 仅在Release模式下"起作用",因为只有在Release模式下,编译器才会进行优化代码,此时我们才需要使用volatile告知编译器某个变量无需优化。Debug环境不会优化,自然就不需要volatile。

如下,我们在Debug环境下执行NotVolatileTest方法,我们可以发现thread0、thread1均成功执行

image-20240509110217053

欢迎补充