并发编程的核心是让程序具备同时处理多个任务的能力,常见的情况是用以解决某些任务太慢但不能让其阻塞总流程以及有某些任务需要同时等待和处理等。而实现并发编程的方法也有很多种,目前我所认知范围中的是多线程(Thread),协程(Continue),多进程(Process)以及异步I/O。
首先需要明确一下线程vs进程:进程是操作系统进行资源分配和调度的基本单位。每个进程拥有独立的虚拟地址空间、代码段、数据段、堆、栈、文件描述符等资源,而线程是进程内的执行单元,共享进程的资源(如内存、文件句柄),但拥有独立的栈和寄存器等执行上下文。所以进程之间隔离性高,进程间通信也较为复杂。而线程共享同一进程下其他线程的读写内存,相应的某一线程炸了进程也会直接炸。一个进程包含至少一个线程。可以说到目前为止我编写的程序是一个只含有单线程的进程作为应用程序运行的。
线程生命周期开始于 System.Threading.Thread 类的对象被创建时,结束于线程被终止或完成执行时。下面列出了线程生命周期中的各种状态:
在 C# 中,System.Threading.Thread 类用于线程的工作。它允许创建并访问多线程应用程序中的单个线程。
属性 | 描述 |
---|---|
CurrentContext | 获取线程正在其中执行的当前上下文。 |
CurrentCulture | 获取或设置当前线程的区域性。 |
CurrentPrincipal | 获取或设置线程的当前负责人(对基于角色的安全性而言)。 |
CurrentThread | 获取当前正在运行的线程。 |
CurrentUICulture | 获取或设置资源管理器使用的当前区域性(以便在运行时查找区域性特定的资源)。 |
ExecutionContext | 获取一个 ExecutionContext 对象,该对象包含有关当前线程的各种上下文的信息。 |
IsAlive | 获取一个值,该值指示当前线程的执行状态。 |
IsBackground | 获取或设置一个值,该值指示某个线程是否为后台线程。 |
IsThreadPoolThread | 获取一个值,该值指示线程是否属于托管线程池。 |
ManagedThreadId | 获取当前托管线程的唯一标识符。 |
Name | 获取或设置线程的名称。 |
Priority | 获取或设置一个值,该值指示线程的调度优先级。 |
ThreadState | 获取一个值,该值包含当前线程的状态。 |
序号 | 方法名 & 描述 |
---|---|
1 | public void Abort() 在调用此方法的线程上引发 ThreadAbortException,以开始终止此线程的过程。调用此方法通常会终止线程。 |
2 | public static LocalDataStoreSlot AllocateDataSlot() 在所有的线程上分配未命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。 |
3 | public static LocalDataStoreSlot AllocateNamedDataSlot( string name) 在所有线程上分配已命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。 |
4 | public static void BeginCriticalRegion() 通知主机执行将要进入一个代码区域,在该代码区域内线程中止或未经处理的异常的影响可能会危害应用程序域中的其他任务。 |
5 | public static void BeginThreadAffinity() 通知主机托管代码将要执行依赖于当前物理操作系统线程的标识的指令。 |
6 | public static void EndCriticalRegion() 通知主机执行将要进入一个代码区域,在该代码区域内线程中止或未经处理的异常仅影响当前任务。 |
7 | public static void EndThreadAffinity() 通知主机托管代码已执行完依赖于当前物理操作系统线程的标识的指令。 |
8 | public static void FreeNamedDataSlot(string name) 为进程中的所有线程消除名称与槽之间的关联。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。 |
9 | public static Object GetData( LocalDataStoreSlot slot ) 在当前线程的当前域中从当前线程上指定的槽中检索值。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。 |
10 | public static AppDomain GetDomain() 返回当前线程正在其中运行的当前域。 |
11 | public static AppDomain GetDomainID() 返回唯一的应用程序域标识符。 |
12 | public static LocalDataStoreSlot GetNamedDataSlot( string name ) 查找已命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。 |
13 | public void Interrupt() 中断处于 WaitSleepJoin 线程状态的线程。 |
14 | public void Join() 在继续执行标准的 COM 和 SendMessage 消息泵处理期间,阻塞调用线程,直到某个线程终止为止。此方法有不同的重载形式。 |
15 | public static void MemoryBarrier() 按如下方式同步内存存取:执行当前线程的处理器在对指令重新排序时,不能采用先执行 MemoryBarrier 调用之后的内存存取,再执行 MemoryBarrier 调用之前的内存存取的方式。 |
16 | public static void ResetAbort() 取消为当前线程请求的 Abort。 |
17 | public static void SetData( LocalDataStoreSlot slot, Object data ) 在当前正在运行的线程上为此线程的当前域在指定槽中设置数据。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。 |
18 | public void Start() 开始一个线程。 |
19 | public static void Sleep( int millisecondsTimeout ) 让线程暂停一段时间。 |
20 | public static void SpinWait( int iterations ) 导致线程等待由 iterations 参数定义的时间量。 |
21 | public static byte VolatileRead( ref byte address ) public static double VolatileRead( ref double address ) public static int VolatileRead( ref int address ) public static Object VolatileRead( ref Object address ) 读取字段值。无论处理器的数目或处理器缓存的状态如何,该值都是由计算机的任何处理器写入的最新值。此方法有不同的重载形式。这里只给出了一些形式。 |
22 | public static void VolatileWrite( ref byte address, byte value ) public static void VolatileWrite( ref double address, double value ) public static void VolatileWrite( ref int address, int value ) public static void VolatileWrite( ref Object address, Object value ) 立即向字段写入一个值,以使该值对计算机中的所有处理器都可见。此方法有不同的重载形式。这里只给出了一些形式。 |
23 | public static bool Yield() 导致调用线程执行准备好在当前处理器上运行的另一个线程。由操作系统选择要执行的线程。 |
主线程是程序中开始运行时由操作系统自动创建的线程,同时也是第一个被执行的线程,是进程的起点,负责执行程序的入口函数(比如说main()函数就是一个主线程)。
结合上面的Thread类,可以通过下面的代码来访问主线程
using System;
using System.Threading;
namespace MyThread
{
class MainThread
{
static void Main(string[] args)
{
Thread mainThread = Thread.CurrentThread;//获取当前正在执行的线程,即主线程
mainThread.Name = "Main Thread";//设置线程名称
Console.WriteLine(mainThread.Name);
}
}
}
class CreatThread
{
public static void Thread1()//定义线程执行的方法
{
Console.WriteLine("Creating Thread1");
}
static void Main(string[] args)
{
ThreadStart ts = new ThreadStart(Thread1);//创建线程委托
Thread t = new Thread(ts);//实例化 Thread 对象
Console.WriteLine("Creating");
t.Start();//启动线程
Thread.Sleep(1);//因为子线程调度需要初始化上下文、等待 CPU 调度等时间,所以如不不加这句会出现形如主线程所有执行完之后再执行子线程的情况(但实际上不然)
Console.WriteLine("FinishCreat");
Console.ReadLine();
}
}
以上代码即为一般而言的线程创建,下面对于线程创建的操作顺序进行一些补充
public static void Thread1()
{
Console.WriteLine("Creating Thread1");
}
这个方法可以是包含参数的也可以是不包含参数的,具体区别体现在下面一个步骤
使用 ThreadStart(不含参数)
或 ParameterizedThreadStart(含参数)
的委托包装方法。但需要注意的是,哪怕使用 ParameterizedThreadStart
方法的参数也只能有一个,如果需要多个参数可以用一个类来封装参数
ThreadStart ts = new ThreadStart(Thread1);
ParameterizedThreadStart ts = new ParameterizedThreadStart(Thread1);//这里的Thread1是含参数的方法
实例化一个线程对象,并且将委托传入该对象。但这是通用写法,对于c#4.0之后可以省略线程委托创建,直接将目标线程方法直接传入线程对象中,这种方法编译器换生成隐式ThreadStart委托来承接方法。这两种方法在绝大多数情况下等价
Thread t = new Thread(ts);
Thread t = new Thread(new ThreadStart(Thread1));
Thread t = new Thread(Thread1);//等效于上一行,但是省去了线程委托的创建
当然也不是所有情况下,具体是当方法有重载时(指同名但签名不同),直接传递方法名可能会存在潜在的报错(一般情况下编译器会自动寻找符合ThreadStart委托的方法,但是如果存在多个或者同时有符合ParameterizedThreadStart委托或者没有符合要求的就会报错)。而且如果有参数则必须使用ParameterizedThreadStart委托。
public static void Method() { } // 匹配 ThreadStart
public static void Method(object obj) { } // 匹配 ParameterizedThreadStart
当然,如果使用Lambda表达式来简化代码,如果使用显式指定委托类型的话需要强制类型转换,直接传递方法代码可读性反而更高一些
Thread thread = new Thread(new ThreadStart(() => Console.WriteLine("Lambda")));
Thread thread = new Thread(() => Console.WriteLine("Lambda"));
配置该该线程的名字,优先级以及是否为后台经常等等(详见Thread的属性)
newThread.Name = "Worker Thread";
newThread.Priority = ThreadPriority.AboveNormal;
newThread.IsBackground = true; // 后台线程(主线程结束则终止)
调用start()方法,使线程进入准备就绪状态,等待操作系统调度,相应的如果线程需要参数也要在start中加入参数。当其对应的Thread1方法执行完之后,线程t也会自动销毁。
t.Start();
t.Start("参数");
Tips:需要注意的是,这里仅仅改变了子线程的状态为准备就绪状态,但是没有立刻调度,需要等待初始化上下文,等待cpu调度等时间,所以体现出来会稍微等待一下再执行。但是如果这个时候主线程中紧接着该操作之后有别的操作就很有可能出现那些操作先于子线程执行,适当的时候需要使用thread中的sleep方法来使主线程等待一定时间再执行(一般1ms就够)。
关于线程的管理有很多,这边选几个常用的来解释一下
使用Join()方法来阻塞当前进程直至目标进程结束
thread1.Join(); // 阻塞当前进程(即主线程)在此等待 thread1 结束
有必要提及的是,如果子线程是线性的方法,那么基本上调用之后就会立刻结束,所以没必要经行暂停与恢复。所以对于暂停与回复操作均为循环类型的方法。
强制暂停与恢复:Thread.Suspend(),Thread.Resume(),但因为会导致死锁问题所以较为过时。
替代方案:使用ManualResetEvent来实现协作式暂停。ManualResetEvent
是 .NET 中的一个线程同步类,属于 System.Threading
命名空间。它通过一个信号状态控制线程的阻塞与唤醒,并且允许多个线程等待同一个事件,并同时被唤醒。具体就以上面提到的例子来改写一下(注意高内聚低耦合的原则以及封装性)
class Thread1
{
private ManualResetEvent mre;
private Thread t1;
public Thread1()
{
this.mre = new ManualResetEvent(true);//初始允许执行
this.t1 = new Thread(act);
}
public void Start()=>t1.Start();
public void act()
{
while (true)
{
this.mre.WaitOne();// 检查暂停状态
Console.WriteLine("act is running");
}
}
public void Reset()=>this.mre.Reset();//暂停线程
public void Set()=>this.mre.Set();//继续线程
}
class TestBox
{
static void Main()
{
Thread1 t1 = new Thread1();
t1.Start();
Console.WriteLine("按 1 暂停线程1,按 2 恢复线程1,按 Q 退出");
while (true)
{
var key = Console.ReadKey(true).KeyChar;
switch (key)
{
case '1':
t1.Reset();
break;
case '2':
t1.Set();
break;
case 'q':
return;
}
}
}
}
当然如果主线程中有多个子线程需要统一控制的时候也可以把ManualResetEvent定义在主线程中并以参数形式来控制子线程。
个人理解下其实ManualResetEvent的实例就相当于个特殊的bool类型的值用以判断当前是否被暂停。不过如果使用bool值的话while循环会进行空转导致性能浪费,而用ManualResetEvent的实例则会使线程主动阻塞,释放 CPU 资源,直到事件触发,避免了忙等待的问题。同时如果使用bool值的话主线程的修改无法立即让子线程看到,而且还会有潜在的编译器和cpu对于变量进行优化导致报错的问题存在。除此之外还有原子性(原子性(Atomicity) 是指一个操作不可被中断,要么完全执行,要么完全不执行。在多线程环境中,非原子操作可能导致数据不一致或竞态条件(Race Condition))的问题,这个放在后面学。而且ManualResetEvent中的WaitOne(int time)中还可以添加等待时间值,意为线程在等待事件信号时,若超过指定时间仍未满足条件,则自动唤醒并继续执行,能够有效的避免死锁,防止无限等待或者处理超时逻辑等。
强制终止:Thread.Abort(),但是因为该方法时强制中止线程,抛出ThreadAbortException的异常(可被try-catch(ThreadAbortException e)捕获)可能会导致后面关于释放内存之类的操作未被执行就结束了线程,导致内存泄漏,所以该方法较为不常用。
至于解决办法,同样有两种,一种使用一个bool值来进行线程的终止,具体如下
volatile bool isRunning = true;//其中volatile指所有线程均可对其访问并修改
void ThreadMethod()
{
while (isRunning)
{
// 业务逻辑
}
}
// 主线程中终止
isRunning = false;
但同样的,这种用bool值也具有上面所说的问题,所以这里需要引入CancellationToken。CancellationToken
是一种协作式取消机制,用于安全、可控地终止线程或异步任务。它与 CancellationTokenSource
配合使用,后者负责生成取消信号。它有如下常见的方法
方法/属性 | 说明 |
---|---|
CancellationToken.IsCancellationRequested |
检查是否已请求取消。 |
CancellationToken.ThrowIfCancellationRequested() |
若取消已触发,抛出 OperationCanceledException 。 |
CancellationToken.Register(Action callback) |
注册取消时的回调函数。 |
CancellationTokenSource.Cancel() |
触发取消信号。 |
CancellationTokenSource.CancelAfter(ms) |
延迟触发取消信号。 |
那么还是结合上面的例子来具体体会一下CancellationTaken是如何运行的
OperationCanceledException
首先需要知道的是,如果要实现终止线程,必须要同时有作为发出取消命令的控制器的CancellationTokenSource对象和作为被监听的取消信号的CancellationToken(一般在子线程中进行判定的是CancellationToken对象,在主线程中进行终止线程操作的是CancellationTokenSource对象),而上面核心逻辑就是当控制器cts触发取消信号时(cts.Cancel()),子线程通过对于取消信号token的监听来终止线程(token.IsCancellationRequested的值为true)。但为了内存释放,同时还需要回调函数token.Register()。需要注意的是,Register的形参是一个委托,所以传值的时候应该是一个委托方法,并且Register在cts.Cancel()时就会立刻自动被调用(不会等到异常信息OperationCanceledException抛出才调用),不需要手动调用。那么按照这个逻辑子线程的IsCancellationRequested + Register()的模板便写好了。
当然我们也可以依赖try-catch语句通过检测token.ThrowIfCancellationRequested()是否抛出OperationCanceledException来写。因为catch语句中的内容是出现异常时的回调,在某些情况下和Register重合,所以在每个token只服务于一个子线程的时候就可以不用写Register()。那么这就是try-catch模板。
try-catch模板适合处理复杂的线程异常以及通过异常机制来处理线程,但是其因为存在异常抛出,性能相比较之下略差。但是当有大量线程且回调内容重复时,可以将CancellationTokenSource和CancellationToken移到主线程,并且使用Register进行全局清理,性能也更优。
通过 Thread.ThreadState
属性获取线程状态:
if ((Thread1.ThreadState & ThreadState.Background) != 0)
{
Console.WriteLine("线程运行中");
}
需要注意的是,ThreadState是一个[Flags]枚举,也就是说可能会同时出现多种状态(如WaitSleepJoin | Background),直接用thread.ThreadState == ThreadState.Background可能不成立,需要用按位与不为零来判断是否包含该状态。下面附上常见的ThreadState的枚举值,可以通过数值来综合多状态
枚举值名 | 数值(十进制) | 说明 |
---|---|---|
Unstarted |
0 |
线程已创建但尚未启动(未调用 .Start() ) |
Running |
2 |
线程正在运行,或准备运行(就绪状态) |
WaitSleepJoin |
32 |
线程处于等待状态,如调用了 Sleep() 、Join() 或 Monitor.Wait() |
Stopped |
16 |
线程执行结束,生命周期结束 |
Background |
64 |
线程是后台线程,不会阻止主程序退出 |
同上,在线程内部通过try-catch捕获异常,避免未处理异常导致进程崩溃
通过 Thread.Priority
调整线程优先级(可能影响系统稳定性,谨慎使用):
thread1.Priority = ThreadPriority.Highest;
前台线程:默认类型,进程需等待所有前台线程结束才终止。
后台线程:设置 IsBackground = true
,进程终止时自动结束。
thread1.IsBackground = true; // 设为后台线程
线程池是一种线程复用机制,用于管理大量线程的创建和销毁,提高系统资源利用率。线程池会维护一定数量的线程,任务被提交后由这些线程处理,避免频繁创建/销毁线程带来的开销。个人理解下来线程池的存在是为了解决单线程反复创建销毁带来的资源以及性能的浪费。而线程池相当于一组固定的线程组,如果有新的人物就从组中拿出一个空闲的线程用,如果用完了再将该空闲线程重新放回组中。
下面是线程池的常见kpi:
方法 | 用法 |
---|---|
ThreadPool.QueueUserWorkItem(callback, state) |
提交一个任务,支持带参数 |
ThreadPool.SetMinThreads(w, io) |
设置最小线程数 |
ThreadPool.SetMaxThreads(w, io) |
设置最大线程数 |
ThreadPool.GetAvailableThreads(out w, out io) |
查看当前可用线程数 |
ThreadPool.RegisterWaitForSingleObject(...) |
注册一个等待对象(比如 ManualResetEvent) |
使用该方法传入的函数如果没有特殊约束会立刻分配空闲的线程进行执行
using System;
using System.Threading;
class Program
{
static void Main()
{
// 向线程池提交一个任务(传入参数)
ThreadPool.QueueUserWorkItem(MyTask, "你好,线程池!");
Console.WriteLine("主线程继续执行...");
Thread.Sleep(1000); // 确保子线程有时间输出
}
static void MyTask(object state)
{
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 子线程池任务执行中:{state}");
}
}
因为在c#中使用这种方法传入的函数必须要含参数,如果要传入不含参数的函数需要用Lambda进行包装:
ThreadPool.QueueUserWorkItem(_ => {
Console.WriteLine("在线程池中运行的无参数任务");
});
这种方法较为复杂和少见,具体在后面讲
// 设置最小线程数(空闲时线程数)
ThreadPool.SetMinThreads(4, 4);
// 设置最大线程数(负载高时的最大并发)
ThreadPool.SetMaxThreads(20, 20);
// 查询当前线程池状态
ThreadPool.GetAvailableThreads(out int worker, out int io);
Console.WriteLine($"可用线程数:{worker}, IO线程数:{io}");
需要注意的是,线程池最多可以创建的线程数是受限于系统资源和线程池的最大线程数配置的。如果需要更精细的控制并发数,可以使用 SemaphoreSlim
等同步工具来限制同时执行的线程数。
RegisterWaitForSingleObject
注册等待事件该方法的模板如下:
public static RegisteredWaitHandle RegisterWaitForSingleObject(
WaitHandle waitObject, // 要等待的对象(比如 ManualResetEvent)
WaitOrTimerCallback callBack, // 触发或超时后要执行的回调
object state, // 传给回调的状态对象
int millisecondsTimeOutInterval, // 超时时间(毫秒)
bool executeOnlyOnce // 是否只执行一次
);
这里个人感觉是最难理解的一部分,我先对上面的 模板惊醒一个解释:首先明确一个大前提是,照这种方式向对象池中添加的线程也好回调函数也好必然会触发,而执行分为被检测对象改变时触发或者是超过指定时间后被检测对象仍未改变触发。第二行要等待的对象实际上就是要检测的对象,比如ManualResetEvent对象(这里分成两种用法,下面会讲)。第三行是一个函数,形参包括(Object state, bool timedOut),其中state由下面一行来定义,而这个timeOut的值由第二行的对象为改变触发(false)或是超过指定时间仍未触发(true)来决定。第四行就是第三行的函数形参的一部分。第五行就是指定的超过时间,以毫秒为单位。第六行就是一个bool值表示是否只执行一次。很头大是不是,我也头大,所以得结合具体例子来弄明白来
上面有讲过,RegisterWaitForSingleObject
这个方法可以用来向线程池中添加线程,但它还有一种用法是用作已加入其中的线程的回调函数。我们下面先从第一种用法入手
上面有讲过,使用ThreadPool.QueueUserWorkItem 添加线程后,线程池会立刻分配空余的线程来执行已添加的线程,这个过程是不受控制的,那么如果想让添加进入的线程能够认为的控制开始就需要用到这种方法,下面为代码:
namespace MyThredPool
{
class Program
{
static ManualResetEvent mre = new ManualResetEvent(false);
static void Main(string[] args)
{
ThreadPool.RegisterWaitForSingleObject(
mre,
Thread1,
"This is State",
3000,
false
);
Console.WriteLine("主线程等待 1 秒后触发事件");
Thread.Sleep(1000);
mre.Set();//触发事件
Console.ReadKey();
}
static void Thread1(Object state, bool timeOut)
{
Console.WriteLine($"【回调】state: {state}, 是否超时: {timeOut}");
}
}
}
首先需要再次申明的是RegisterWaitForSingleObject是用来申明回调函数的,但是可以通过取巧来使它做到形如向线程池中添加线程的效果。首先,上面的RegisterWaitForSingleObject检测的是ManualResetEvent的对象mre的所监听线程是否触发。而mre初始值是false,也就是它所监听的事件处于未触发状态,那么当mre.Set()执行后以为着它所监听的事件执行了,那么就要执行回调函数Thread1,而且因为没有超时所以timeOut的值为false。如果当三秒后mre.Set()仍未执行,那么就证明此时超时未执行,那么依旧执行回调函数Thread1(),不过timeOut的值为true。而回调函数的state就是下面所谓的”This is State“。
当然,上面的表述是我勉强按照这个RegisterWaitForSingleObject的原用法来讲的,仔细看也能发现,mre本来是一个检测线程是否被暂停的指示器,但是这段代码他并没有检测任何线程,那么也就是说,理论上上面的mre完全可以被一个bool类型的变量所取代,作用也几乎相同,但实际上会引发线程安全等问题。那么上面的代码就做到了在mre.Set()执行回调函数,也可以理解为在执行了mre.Set()时从线程池中运行子线程。这就是所谓的第二种添加线程方法。
还有一点就是,但设定时间为0或者mre初始值为true时,会立刻执行回调函数,但是这都是没有意义的。
上面也有讲到,RegisterWaitForSingleObject的本意是回调函数,用于在检测的指定线程触发时或者超时未触发时执行回调函数,那么就需要将ManualResetEvent绑定到指定线程上,具体代码如下
namespace MyThredPool
{
class Program
{
static ManualResetEvent mreA = new ManualResetEvent(false);
static ManualResetEvent mreB = new ManualResetEvent(false);
static void Main(string[] args)
{
//线程加入线程池
ThreadPool.QueueUserWorkItem(ThreadProc, ("actionA",mreA));
ThreadPool.QueueUserWorkItem(ThreadProc, ("actionB", mreB));
//回调函数的设置
ThreadPool.RegisterWaitForSingleObject(mreA,PayBackA,null,3000,false);
ThreadPool.RegisterWaitForSingleObject(mreB,PayBackB,null,3000,false);
//线程的调用
Thread.Sleep(1000);
Console.WriteLine("ActionA");
mreA.Set();
Thread.Sleep(1000);
Console.WriteLine("ActionB");
mreB.Set();
Console.ReadLine();
}
static void ThreadProc(Object state)
{
var (str,mre) = ((string,ManualResetEvent))state;
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] {str} is starting");
mre.WaitOne();
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] {str} is finished");
}
static void PayBackA(Object state, bool timedOut)
{
if(timedOut)
{
Console.WriteLine("ActionA is not done");
}
else
{
Console.WriteLine("ActionA is done");
}
}
static void PayBackB(Object state, bool timedOut)
{
if(timedOut)
{
Console.WriteLine("ActionB is not done");
}
else
{
Console.WriteLine("ActionB is done");
}
}
}
}
这里就是通过QueueUserWorkItem方法向线程池中加入了AB两个线程,同时用mreA和mreB阻塞了线程的运行,同时将这两个对象分别挂到了AB两个线程中,当着两个线程继续执行后,那么就会触发所对应的回调函数paybackA和paybackB。从而通过mreA和mreB既实现了线程池中的线程在指定区域执行,也实现了通过RegisterWaitForSingleObject将回调函数挂在这两个线程上。
不过需要注意的是,所谓的控制线程在指定区域执行并不严谨,因为之后mre.WaitOne()后面的语句被阻塞,也就是说当这两个线程加入线程时其实已经立刻执行了,只不过直到遇到mre.WaitOne()才停止继续执行(也就是再加入线程池后已经执行了
var (str,mre) = ((string,ManualResetEvent))state; Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] {str} is starting");
这两行代码了)
其实有关于线程安全在上面讲到某些线程管理操作时已经提及,这里进行一个汇总
原子性问题指操作不是“不可分割”的,多个线程竞争执行会出现中间状态或丢失更新。也就是说,某些操作看起来是一步完成的,其实背后拆成了多步(也就是非原子的)。如果多个线程同时执行这些非原子操作,就会产生丢失更新等问题。
最常见的就是x++操作看似是一步操作,但实际上包括了读取自增写入三部操作,如果同时有三个子线程同时对这个变量进行了操作,理论上会自增3,但实际上因为三个子线程读取到了同一个值导致被覆盖最后只自增了1。
解决方法如下,其中lock和monitor在后面介绍,这里主要讲一下Interlocked
类:该类需要申明命名空间System.Thread,常用于线程安全地执行简单的原子操作,例如 +1
、-1
、交换引用等。所以说当线程内的部分简单的数值类操作可以使用InterLocked来避免原子性问题
Interlocked.Increment(ref counter); // 原子 +1
Interlocked.Decrement(ref counter); // 原子 -1
Interlocked.Exchange(ref value, newValue); // 原子交换
Interlocked.CompareExchange(ref value, newValue, comparand); // CAS操作
方法 | 说明 |
---|---|
lock (互斥锁) |
强制多个线程排队访问共享代码段 |
Monitor.Enter/Exit |
与 lock 类似,是底层控制方式 |
Interlocked 类 |
适用于原子操作(加减/交换) |
可见性问题主要指某线程修改变量的值,另一个线程看不到。因为 CPU 会将变量缓存到寄存器或 CPU 缓存中,线程不会及时同步到主内存。容易出现更新不同步等问题
至于解决方法:1.可以将需要多线程共享的数据设置为volatile,即多线程均可修改并查看。2.使用lock来保证一次只能有一个线程调用该共享数据从而避免可见性问题。3.说存储这些需要多线程共享的数据时使用线程安全集合,即一种特殊的数据结构,不用加锁,天然线程安全。不过需要引入命名空间System.Collections.Concurrent,有以下几种常见类型:
ConcurrentQueue
:线程安全队列(先进先出)
ConcurrentStack
:线程安全栈
ConcurrentDictionary
:线程安全字典
BlockingCollection
:带容量限制、可阻塞的集合(用于生产者-消费者模型)
有序性问题指编译器或 CPU 为优化性能会对指令重新排序,如果不加约束,可能会打乱原本期望的执行顺序,导致异常行为。具体可以参考下面的例子
int a = 0, b = 0;
int x = 0, y = 0;
Thread1:
a = 1;
x = b;
Thread2:
b = 1;
y = a;
// 有可能出现:x = 0, y = 0
常见的解决方法:1.使用lock 2.使用高层同步机制 3.手动添加内存屏障
数据竞争问题指多个线程同时读写同一资源,且至少一个是写操作。从而导致某个线程读取的数据可能是另外一个线程修改过的数据,从而出现错误
至于解决方法依旧可以使用lock限制每次只能有一个线程访问该数据,或者使用上面提到的线程安全集合
lock实际上是Monitor.Enter
和 Monitor.Exit
的语法糖。它的作用是保证lock范围内的数据以及一些操作同时只能限定一个线程使用,这样就较好的避免了多个线程同时调用或者使用该内容容易造成的线程安全问题,具体操作如下
object locker = new object();
void Increase()
{
//不需要被锁的区域
lock (locker)
{
需要被锁的区域
}
}
首先lock()括号中需要跟一个object类型的对象,所以需要在最开始声明这个对象(如上面的代码的第一行)。不过因为这个lock()中的内容是一个引用类型,表示锁的对象,用以控制同时只能有一个线程进入,所以这个locker的定义需要时与所有线程平行的,即所有线程可以访问到。需要注意的是,lock的作用实际上是将多线程中的某些操作给强制换成了单线程,所以尽可能地只在需要被多个线程复用或者存在安全隐患地区域使用lock(而且要避免虚锁),不然多线程就会被无数个锁变成单线程。值得一提的是,多线程设计地理想目标时尽可能减少锁的范围(只包含必要共享资源),尽量减少锁的数量(避免滥用),尽量避免锁的嵌套(容易死锁)。
上面有讲到,锁lock是用来限制共享资源的访问的,那么如果我们有多个独立的共享资源,那么势必就会有可能开多个锁,那么如果这个时候某些线程需要同时调用多个共享资源,那么就会牵扯到锁的嵌套问题。那么如果出现
Thread1:
lock (B)
{
lock (A)
{
// ...
}
}
Thread2:
lock (A)
{
lock (B)
{
// ...
}
}
那么就会出现两个线程互相等待对方释放资源,导致程序卡死。这就是死锁问题。死锁问题的产生一般同时具备以下四个条件:1.互斥条件:每次只能一个线程访问资源。2.占有且等待:一个线程占有部分资源同时等待其他资源的释放。3.不可剥夺:线程获得的资源部会被强制剥夺只能主动释放。4.循环等待:线程之间形成等待链条A→B→C→A。
那么关于死锁问题的解决,一般来说就是通过优化锁的结构(如锁的嵌套都按照A→B的顺序)或者尽可能减少锁的使用来避免出现死锁对的出现,或者是使用Monitor.TryEnter()来设置超时
上面也有说到,lock实际就相当于Mnoitor的一个语法糖
lock (someObj)
{
// 临界区
}
编译后就等价于
object temp = someObj;
bool lockTaken = false;
try
{
Monitor.Enter(temp, ref lockTaken);//尝试获取锁,如果成功就把lockTaken设置为true
// 临界区
}
finally
{
if (lockTaken)
Monitor.Exit(temp);
}
下面具体解释一下Monitor的具体用法
Monitor.Enter / Exit是Monitor中负责加锁解锁的方法,而且一定要配合try-finally使用避免出现锁永远不释放导致死锁的问题。
object _lock = new object();
Monitor.Enter(_lock);
try
{
// 临界区代码
}
finally
{
Monitor.Exit(_lock); // 必须放在 finally 中!
}
Monitor.TryEnter正如其名是尝试获取一个锁,效果大体与上面的Enter相同(注意会有一个bool类型的返回值),但是可以通过设置超时等待时间来避免死锁
object _lock = new object();
if (Monitor.TryEnter(_lock, TimeSpan.FromMilliseconds(100)))//设置等待时间
{
try
{
Console.WriteLine("获得锁,安全访问资源");
}
finally
{
Monitor.Exit(_lock);
}
}
else
{
Console.WriteLine("获取锁失败,可以选择重试或放弃");
}
通过Monitor.Wait / Pulse / PulseAll来实现线程间的协作。
方法 | 说明 |
---|---|
Monitor.Wait(obj) |
当前线程释放锁并等待,直到被 Pulse 唤醒 |
Monitor.Pulse(obj) |
唤醒一个等待该锁的线程(系统随机指定) |
Monitor.PulseAll(obj) |
唤醒所有等待该锁的线程 |
object _lock = new object();
Queue queue = new Queue();
// 生产者线程
void Producer()
{
lock (_lock)
{
queue.Enqueue(1);
Monitor.Pulse(_lock); // 唤醒一个等待的消费者线程
}
}
// 消费者线程
void Consumer()
{
lock (_lock)
{
while (queue.Count == 0)
{
Monitor.Wait(_lock); // 没数据就挂起自己,等待被唤醒
}
int item = queue.Dequeue();
}
}
值得一体的是Pulse和PulseAll方法对于等待该锁的线程都是在方法调用后立刻唤醒的,但是线程具体执行要等到当前线程释放了该锁之后才会开始执行
本博客纯属个人理解,以个人回顾为主,如有任何错误欢迎批评指正。