C# 多线程学习笔记 - 2
本文主要针对 GKarch 相关文章留作笔记,仅在原文基础上记录了自己的理解与摘抄部分片段。
遵循原作者的 CC 3.0 协议。
如果想要了解更加详细的文章信息内容,请访问下列地址进行学习。
一、同步概要
同步构造基本分为四种,简单的阻塞方法、锁构造、信号构造、非阻塞同步构造。
1.1 阻塞方法
- 阻塞方法一般是会暂停某些线程的执行,例如
Sleep()
与Join()
方法。当一个线程被阻塞的时候,会立即出让(yields) CPU 时间片,不再消耗处理器时间。 - 通过检查线程的
ThreadState
属性来确认某个线程是否被阻塞。 - 当某个线程被阻塞或者解除阻塞的时候,会进行上下文切换。
- 阻塞方法在满足以下几个条件的时候会进行解除。
- 阻塞条件满足。
- 操作超时。
- 通过
Thread.Interrupt()
中断。 - 通过
Thread.Abort()
中止。
1.2 阻塞与自旋
-
信号构造与锁构造可以在某些条件被满足前阻塞线程。另外一种方法就是通过自旋来等待条件被满足。
-
自旋即通过一个循环不断检测条件,来伪造一个空忙状态。
-
虽然自旋会造成大量的处理器时间浪费,但是它可以避免上下文切换带来的额外开销。
-
一个标准的自旋结构如下列代码。
// 单纯的自旋 while(!proceed); // 阻塞 + 自旋 while(!proceed) Thread.Sleep(10);
二、锁
2.1 排它锁
-
排它锁的作用是为了保证线程安全,如下列代码。如果
Go()
方法被两个线程同时执行,则可能某个线程在执行完if
后,另一个线程已经将V2
置为0,原先线程就可能造成除数不能为 0 的异常。class Code1 { static int V1 = 1,V2 = 1; static void Go() { if(V2 != 0) Console.WriteLine(V1 / V2); V2 = 0; } } // 使用了排它锁的代码 class Code2 { static int V1 = 1,V2 = 1; static readonly object locker = new object(); static void Go() { lock(locker) { if(V2 != 0) Console.WriteLine(V1 / V2); V2 = 0; } } }
-
如果使用了
lock
语句快,则可以锁定一个同步对象,其他竞争锁的线程会被阻塞,直到锁被释放。 -
如果有多个线程竞争锁,则按照先到先得的队列进行排队,通过排它锁可以强制线程对锁保护的内容进行顺序访问。
-
在竞争锁时被阻塞的线程,其状态为
WaitSleepJoin
。 -
不同的同步结构技术的性能开销。
构造 用途 开销 lock
( Monitor.Enter / Monitor.Exit )确保同一时间只有一个线程可以访问资源或代码 20 ns Mutex 确保同一时间只有一个线程可以访问资源或代码 1000 ns SemaphoreSlim 确保只有不超过指定数量的线程可以并发访问资源或代码 200 ns Semaphore 确保只有不超过指定数量的线程可以并发访问资源或代码 1000 ns ReaderWriterLockSlim 允许多个读线程和一个写线程共存 40 ns ReaderWriterLock
(已过时)允许多个读线程和一个写线程共存 100 ns
2.2 Monitor.Enter 与 Monitor.Exit
-
lock
语句块实质上就是一个语法糖,其核心代码就是结合try/finally
来调用Monitor.Enter()
与Monitor.Exit()
方法,并且如果在一个方法内直接调用Monitor.Exit()
会直接抛出异常。Monitor.Enter(locker); try { if(V2 != 0) Console.Writeline(V1 / V2); V2 = 0; } finally { Monitor.Exit(locker); }
-
上述情况可能发生锁泄漏,因为在
Monitor.Enter()
与try/finally
语句块之间如果发生了异常,会导致后续的try/finally
语句块不被执行。造成无法获得锁,或者得到锁之后,无法释放造成锁泄漏。 -
解决锁泄漏的方式是,CLR 4.0 当中,对于
lock
语句的翻译则是通过一个bool
类型的lockTaken
进行解决。bool lockTaken = false; try { Monitor.Enter (locker, ref lockTaken); // 用户代码 ... } finally { if(lockTaken) { Monitor.Exit(locker); } }
-
Monitor
还提供了TryEnter()
方法,用于执行超时时间,如果超过时间没有获得到锁,则返回false
。
2.3 什么时候加锁
-
需要访问任意可写的共享字段,下面代码展示了线程安全与非线程安全的代码。
class ThreadUnsafe { static int _x; static void Increment() { _x++; } static void Assign() { _x = 123; } } // 线程安全 class ThreadSafe { static readonly object _locker = new object(); static int _x; static void Increment() { lock(_locker) _X++; } static void Assign() { lock(_locker) _x++; } }
2.4 锁与原子性
- 如果一组变量总是在一个锁内进行读且,即可被成为原子的读写。
- 例如
lock(locker) { if(x != 0) y /= x; }
就可以说x
与y
是被原子访问的,因为这段代码无法被其他线程分割或者抢占。 - 如果在
lock
锁内抛出异常,将会影响锁的原子性,这个时候就需要结合回滚机制来进行实现。
2.5 锁嵌套
- 排他锁是可以被嵌套的,并且只有当最外层的锁被释放的时候,对象才会被解锁。
- 线程只会在最外层的
lock
语句处被阻塞。
2.6 死锁
-
死锁是当两个甚至多个线程所等待的资源都被对方占用的时候,它们都无法执行,就会产生了死锁。
-
一个标准的死锁代码如下,我们在 A 线程内部锁定了
locker1
与locker2
,在主线程同同时也锁定了locker2
与locker1
。这个时候由于排他锁的特性,主线程与新开启的线程都会等待对方的锁被释放,造成死锁。object locker1 = new object(); object locker2 = new object(); new Thread(() => { lock(locker1) { Thread.Sleep(1000); lock(loekcer2); } }).Start(); lock(locker2) { Thread.Sleep(); lock(locker1); }
-
应该尽量较少对锁的使用,更多的依靠其他的同步构造进行处理。
2.7 互斥体
- 互斥体使用
WaitOne()
方法进行加锁,使用ReleaseMutex()
来解锁。 - 关闭或者销毁
Mutex
对象会自动释放锁,所以可以结合using
语句块进行使用。 - 互斥体是机器范围的,其性能比
lock
慢约 50 倍。
2.8 信号量
-
信号量具有一定容量,当容量满了之后和就会拒绝其他线程占用,当有一个线程释放资源之后,其他线程按先后顺序进入。
class Program { static void Main(string[] args) { var sem = new Semaphore(); for (int i = 1; i <= 5; i++) new Thread(sem.Enter).Start(i); } } public class Semaphore { private readonly SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(3); public void Enter(object id) { Console.WriteLine($"Id 为 {id} 的线程想调用本方法。"); _semaphoreSlim.Wait(); Console.WriteLine($"Id 为 {id} 的线程已经进入方法。"); Thread.Sleep(1000 * (int)id); Console.WriteLine($"Id 为 {id} 的线程正在离开方法。"); _semaphoreSlim.Release(); } }
-
容量为 1 的信号量与
Mutex
和lock
类似。 -
信号量是线程无关的,任何线程都可以调用
Release()
方法释放信号量。而Mutex
与lock
只有获得锁的线程才可以释放。 -
在 .NET 4.0 当中有一个轻量级的信号量
SemaphoreSlim
,但是不是跨进程的,开销只有Semaphore
的四分之一。 -
一般在某些需要限流或者是要执行比较密集的磁盘 I/O 操作,这个时候可以使用信号量进行并发限制,这样可以改善程序整体的性能。
三、线程安全
- 如果某个程序或者方法是一个线程安全的,那么它在任意的多线程场景中都不会存在不确定性。
- 线程安全是通过锁,或者减少线程交互来实现的。
- 多线程当中的线程安全一般是在需要的时候才会进行实现,但是可以以牺牲粒度,将整段代码甚至是整个对象封装在一个排它锁的内部。这种解决方案十分简单有效,这种方案适用于使用了线程不安全的第三方库代码,并且仅适用于能够快速执行的场景,否则会产生大量阻塞。
- 除了 CLR 定义的基本类型以外,很少有能够在高并发需求下保证其实例是线程安全的,除了 Concurrent 下的并行集合以外。
- 可以最小化共享数据来减少线程交互,例如 Web 服务器不需要持久化并发请求的数据,是无状态的,线程交互的时候仅需要考虑静态字段等共享资源。
- 除了以上两种方法之外,也可以使用自动锁机制,集成
ContextBoundObject
类并且使用Synchronization
特性。但是这种方法很容易造成死锁的情况,并且降低并发度。
3.1 线程安全与 BCL 类型
-
通过锁可以将不安全的代码转换为线程安全的代码,例如 BCL 提供的
List<T>
集合本身不是线程安全的,但是通过对一个集合实例的锁定,我们就可以进行线程安全的操作。下面的代码当中,我们直接使用List<int>
集合自身来加锁,这里对集合进行遍历的操作也不是线程安全的,也需要加锁进行处理,另一种方式就是通过读写锁来实现避免长时间锁定。class Program { static void Main(string[] args) { var bcl = new BCLThreadSafe(); for (int i = 0; i < 10; i++) { new Thread(bcl.AddItem).Start(); } } } public class BCLThreadSafe { private readonly List<int> _innerList = new List<int>(); public void AddItem() { lock (_innerList) { _innerList.Add(_innerList.Count); } var sb = new StringBuilder(); lock (_innerList) { foreach (var item in _innerList) { sb.Append(item).Append(','); } } Console.WriteLine(sb.ToString().TrimEnd(',')); } }
-
即便
List<T>
集合是线程安全的,如果我们需要使用以下代码增加一个新的数据到集合当中。也会由于在执行if
之后,其他线程抢占修改了_list
集合,增加了一个相同的类目。在这个时候,对_list
集合的添加操作就是存在问题的。if(!_list.Contains(newItem)) _list.Add(newItem);
-
在高并发的环境下,对集合的访问加锁可能产生大量阻塞,所以进行类似操作的时候建议使用线程安全的队列、栈、字典。
-
针对于静态成员,BCL 的所有类型的静态成员都实现了线程安全,所以开发人员在开发基础类型或者框架的时候,应该保证静态成员的线程安全。
-
大部分 BCL 类型的只读访问都是线程安全的,开发人员在设计类基础类型或者框架的时候也应该遵循这个规则。
3.2 应用服务器其与线程安全
-
服务端经常需要使用到多线程处理客户请求,也就意味着必须考虑线程安全。但一般来说服务端类都是无状态的,或者为每个请求创建新的对象实例,很少存在有交互的点。
-
以缓存为例,假设对一个用户表使用了静态的字典实例进行缓存,那么就存在线程安全的问题。下列代码在读取与更新锁的时候,使用了排它锁进行加锁处理。但是会存在两个线程同时访问
GetUser()
方法的时候,都传递了未缓存过得数据的id
,这个时候就会去查询两次数据库。虽然可以通过对整个GetUser()
加锁,但是这样设计的话,都会在QueryUser()
进行查询的时候,整个获得用户信息的方法都被阻塞。static class UserCache { static Dictionary<int,User> _users = new Dictionary<int,User>(); internal static User GetUser(int id) { User u = null; lock(_users) { if(_users.TryGetValue(id,out u)) { return u; } } // 从数据库查询用户数据 u = QueryUser(id); lock(_users)_users[id] = u; return u; } }
3.3 WPF 与 WinForm 程序的线程安全
-
富客户端程序一般都是基于
DependencyObject
(WPF) 与Control
(Windows Forms),它们都具备线程亲和性,即只有创建他们的线程才能够访问其成员。 -
作用就是访问 UI 对象并不需要加锁,坏处则是如果要跨线程调用 UI 控件则需要一些比较繁琐的步骤。
- WPF:其
Dispatcher
调用Invoke()
或BeginInvoke()
。 - Windows Forms:调用
Control()
对象的Invoke()
或BeginInvoke()
。
- WPF:其
-
Invoke()
与BeginInvoke()
都接收一个委托以便代替工作线程需要在 UI 线程执行的操作。前者是同步方法,在委托执行完成之前,都处于阻塞状态。后者是异步方法,调用方立刻返回。// WPF DEMO public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); new Thread(Work).Start(); } private void Work() { Thread.Sleep(5000); // 阻塞当前线程 5s 模拟耗时任务 UpdateMessage("new msg"); } private void UpdateMessage(string msg) { var action = () => txtMessage.Text = msg; Dispatcher.Invoke(action); } } // Windows Forms DEMO public partial class FormClass : Form { // ... 其他代码 private void UpdateMessage(string msg) { var action = () => txtMessage.Text = msg; this.Invoke(action); } // ... 其他代码 }
3.4 不可变对象
- 不可变对象拥有的不变性可以在多线程环境中最小化共享可写状态的问题。
四、事件等待句柄与信号同步
-
事件等待句柄的作用是用于进行信号同步。
-
信号同步即一个线程进行等待,直到其接受到其他线程通知的过程。
-
信号构造的开销比较。
构造 用途 开销 AutoResetEvent 使线程在接收到其他线程信号时解除阻塞一次。 1000 ns ManualResetEvent 使线程在接收到其他线程信号时解除阻塞,并不继续
阻塞,直到其复位。1000 ns ManualResetEventSlim 使线程在接收到其他线程信号时解除阻塞,并不继续
阻塞,直到其复位。40 ns CountdownEvent 使线程在收到预订数量的信号时,解除阻塞。 40 ns Barrier 实现线程执行屏障。 80 ns Wait 和 Pulse 使线程阻塞,直到自定义条件被满足。 Pulse/120 ns
4.1 AutoResetEvent
-
AutoResetEvent
的原理类似于验票闸机,在闸机处调用WaitOne()
方法,线程就会被阻塞。插入票的动作就类似于调用Set()
方法打开闸机。任何能够访问这个AutoResetEvent
的非阻塞线程都可以调用Set()
方法来放行一个被阻塞的线程。 -
AutoResetEvent
是基于EventWaitHandle
进行构造的,有两种方法可以创建AutoResetEvent
对象。第一种即通过其构造方法var auto = new AutoResetEvent (false);
,第二种则是通过EventWaitHandle
传递事件类型,var auto = new EventWaitHandle (false, EventResetMode.AutoReset);
。这里如果传递的是false
则会在创建后立即调用Set()
方法。class Program { public static readonly EventWaitHandle WaitHandle = new AutoResetEvent(false); static void Main(string[] args) { var testClass = new AutoResetEventTest(); new Thread(testClass.Waiter).Start(); // 主线程等待 1 秒再发送信号唤醒 Thread.Sleep(1000); WaitHandle.Set(); } } public class AutoResetEventTest { public void Waiter() { Console.WriteLine("线程开始等待..."); // 如果传入了超时时间,超时则返回 false。 Program.WaitHandle.WaitOne(); Console.WriteLine("接受到了通知,进入闸机。"); } }
-
如果没有线程等待的时候调用
Set()
方法,则等待句柄会保持初始状态,直到有线程调用了WaitOne()
方法。 -
为等待句柄调用
Reset()
方法可以关闭闸机,这个方法不会被阻塞。 -
可以调用
Dispose()
方法来销毁等待句柄,或者直接丢弃,等待 GC 进行回收。 -
如果主线程需要向工作线程连续发送 3 个信号并结束线程,则可以通过双向信号进行实现,其步骤大体如下。
- 启动工作线程。
- 主线程通过事件等待句柄 A 等待工作线程就绪。
- 工作线程通过 A 句柄通知主线程就绪,工作线程通过事件等待句柄 B 等待主线程通知。
- 主线程更改某个共享数据,通过句柄 B 通知工作线程进行处理。
- 工作线程收到信号唤醒之后,输出共享数据,并判断是否应该结束线程。
- 循环往复三次之后,工作线程收到的共享数据为
null
,工作线程进行退出。
static void Main(string[] args) { var testObj = new MultiAutoResetEventTest(); new Thread(testObj.WorkThread).Start(); MultiAutoResetEventTest.WaitHandle_MainThread.WaitOne(); lock (MultiAutoResetEventTest.Locker) MultiAutoResetEventTest.Message = DateTime.Now.ToFileTimeUtc().ToString(); MultiAutoResetEventTest.WaitHandle_WorkThread.Set(); MultiAutoResetEventTest.WaitHandle_MainThread.WaitOne(); lock (MultiAutoResetEventTest.Locker) MultiAutoResetEventTest.Message = DateTime.Now.ToFileTimeUtc().ToString(); MultiAutoResetEventTest.WaitHandle_WorkThread.Set(); MultiAutoResetEventTest.WaitHandle_MainThread.WaitOne(); lock (MultiAutoResetEventTest.Locker) MultiAutoResetEventTest.Message = DateTime.Now.ToFileTimeUtc().ToString(); MultiAutoResetEventTest.WaitHandle_WorkThread.Set(); MultiAutoResetEventTest.WaitHandle_MainThread.WaitOne(); lock (MultiAutoResetEventTest.Locker) MultiAutoResetEventTest.Message = null; MultiAutoResetEventTest.WaitHandle_WorkThread.Set(); } } public class MultiAutoResetEventTest { public static readonly EventWaitHandle WaitHandle_MainThread = new AutoResetEvent(false); public static readonly EventWaitHandle WaitHandle_WorkThread = new AutoResetEvent(false); public static string Message = string.Empty; public static readonly object Locker = new object(); public void WorkThread() { while (true) { WaitHandle_MainThread.Set(); WaitHandle_WorkThread.WaitOne(); lock (Locker) { if (Message == null) return; Console.WriteLine($"收到主线程的消息,内容为: {Message}"); } } } }
-
生产消费者队列的构成如下所描述的一致。
- 构建一个队列,用于存放需要执行的工作项。
- 如果有新的任务需要执行,将其放在队列当中。
- 一个或多个工作线程在后台执行,从队列中拿取工作项执行,将其消费。
-
生产/消费者队列可以精确控制工作线程的数量,CLR 的线程池就是一种生产/消费者队列。
-
结合
AutoResetEvent
事件等待句柄,我们可以很方便地实现一个生产/消费者队列。class Program { static void Main(string[] args) { using (var queue = new ProducerConsumerQueue()) { queue.EnqueueTask("Hello"); for (int i = 0; i < 10; i++) { queue.EnqueueTask($"{i}"); } queue.EnqueueTask("End"); } } } public class ProducerConsumerQueue : IDisposable { private readonly EventWaitHandle _waitHandle = new AutoResetEvent(false); private readonly object _locker = new object(); private readonly Queue<string> _taskQueue = new Queue<string>(); private readonly Thread _workThread; public ProducerConsumerQueue() { _workThread = new Thread(Work); _workThread.Start(); } public void EnqueueTask(string task) { // 向队列当中插入任务,加锁保证线程安全 lock (_locker) { _taskQueue.Enqueue(task); } // 通知工作线程开始干活 _waitHandle.Set(); } private void Work() { while (true) { string task = null; lock (_locker) { if (_taskQueue.Count > 0) { task = _taskQueue.Dequeue(); if (task == null) return; } } if (task != null) { Thread.Sleep(100); Console.WriteLine($"正在处理任务 {task}"); } else { // 如果任务等于空则阻塞线程,等待心的工作项 _waitHandle.WaitOne(); } } } public void Dispose() { // 优雅退出 EnqueueTask(null); _workThread.Join(); _waitHandle.Close(); } }
-
.NET 4.0 以后提供了一个
BlockingCollection<T>
类型实现了生产/消费者队列。
4.2 ManualResetEvent
-
与
AutoResetEvent
类似,但在调用Set()
方法的时候打开门,是可以允许任意数量的线程在调用WaitOne()
后通过。(与AutoResetEvent
每次只能通过 1 个不一样) -
如果是在关闭状态下调用
WaitOne()
方法,线程会被阻塞,其余功能都与AutoResetEvent
一致。 -
ManualResetEvent
的基类也是EventWaitHandle
,通过以下两种方式均可构造。var manual1 = new ManualResetEvent(false); var manual2 = new EventWaitHandle(false, EventResetModel.ManualReset);
-
.NET 4.0 提供了性能更高的
ManualResetEventSliam
,但是不能够跨线程使用。
4.3 CountdownEvent
-
使用
CountdownEvent
可以指定一个计数器的值,用于表明需要等待的线程数量。 -
调用
Signal()
方法会将计数器自减 1 ,如果调用其Wait()
则会阻塞计数到 0 ,通过AddCount()
可以增加计数。class Program { static void Main() { var test = new CountdownEventTest(); new Thread(test.Say).Start("Hello 1"); new Thread(test.Say).Start("Hello 2"); new Thread(test.Say).Start("Hello 3"); test.CountdownEvent.Wait(); Console.WriteLine("所有线程执行完成..."); } } public class CountdownEventTest { public readonly CountdownEvent CountdownEvent = new CountdownEvent(3); public void Say(object info) { Thread.Sleep(1000); Console.WriteLine(info); CountdownEvent.Signal(); } }
-
当计数为 0 的时候,无法通过
AddCount()
增加计数,只能调用Reset()
进行复位。
4.4 等待句柄与线程池
-
除了手动开启线程之外,事件等待句柄也支持通过线程池来运行工作任务。
-
通过
ThreadPool.RegisterWaitForSingleObject()
方法可以减少资源消耗,当需要执行的委托处于等待状态的时候,不会浪费线程资源。class Program { static void Main() { var test = new ThreadPoolTest(); test.Test(); } } public class ThreadPoolTest { private readonly EventWaitHandle _waitHandle = new ManualResetEvent(false); public void Test() { RegisteredWaitHandle regHandle = ThreadPool.RegisterWaitForSingleObject(_waitHandle, Work, "OJBK", -1, true); Thread.Sleep(1000); _waitHandle.Set(); Console.ReadLine(); regHandle.Unregister(_waitHandle); } public void Work(object data,bool timeout) { Console.WriteLine($"正在执行任务 {data} ....."); } }
-
上述代码如果通过传统的方式进行阻塞与信号发送, 那么有 1000 个请求
Work()
方法,就会造成大量服务线程阻塞,而RegisterWaitForSingleObject
可以立即返回,不会浪费线程资源。
4.5 跨进程的 EventWaitHandle
可以通过对 EventWaitHandle
类型构造函数的第三个参数传入标识,来获得跨进程的事件的等待句柄。
EventWaitHandle wh = new EventWaitHandle(false,EventResetMode.AutoReset,"AppName.Identity");
五、同步上下文
5.1 使用同步上下文
- 这里的同步上下文不是
SynchronizationContext
类,而是 CLR 的自动锁机制。 - 通过继承
ContextBoundObject
基类并添加Synchronization
特性即可让 CLR 自动加锁。 - 同步上下文是一种比较重型的加锁方法,很容易引起死锁的情况发生。
5.2 重入
- 线程安全方法也被称之为可重入的,因为其可以在运行途中被其他线程抢占。
- 使用了自动锁会有一个严重问题,如果将
Synchronization
特性的reentrant
参数设置为true
。则允许同步类是可被重入的,这就导致同步上下文被临时释放,会导致过度期间任何线程都可以自由调用原对象的任何方法。 - 这是因为
Synchronization
特性是直接作用于类,所以其所有方法都会带来可重入的问题。 - 所以因尽量减少粗粒度的自动锁。