第22章 高级线程处理
22.1 同步概述
多线程我们常需要一些同步结构进行线程间通讯。同步结构可以分为三类:
- 互斥锁
一次只允许一个线程执行特定的活动或一段代码。主要目的是令线程访问共享的写状态而不互相影响。互斥锁包括 lock
、Mutex
和 SpinLock
。
2. 非互斥锁
实现了有限的并发性。非互斥锁包括 Semaphore(Slim)
和 ReaderWriterLock(Slim)
。
3. 信号发送结构
信号发送结构允许线程在接到一个或者多个其他线程的通知之前保持阻塞状态。信号发送结构包括 ManualResetEvent(Slim)
、AutoResetEvent
、CountdownEvent
和 Barrier
。前三者就是所谓的事件等待句柄(event wait handles)。
22.2 互斥锁
22.2.1 lock 语句
22.2.2 Monitor.Enter
方法和 Monitor.Exit
方法
C# 的 lock
语句是包裹在 try-finally 语句块中的 Monitor.Enter
和 Monitor.Exit
语法糖,以下两段代码等价(有简化):
lock (_locker)
{if (_val2 != 0) Console.WriteLine (_val1 / _val2);val2 = 0;
}
bool lockTaken = false;
try {Monitor.Enter(_locker, ref lockTaken);if(_val2 != 0)Console.WriteLine(_val1 / _val2);_val2 = 0;
}
finally {if (lockTaken)Monitor.Exit(_locker);
}
又见 lock & Monitor
22.2.2.1 lockTaken 重载
bool lockTaken = false; try {Monitor.Enter(_locker, ref lockTaken);if(_val2 != 0)Console.WriteLine(_val1 / _val2);_val2 = 0; } finally {if (lockTaken)Monitor.Exit(_locker); }
这段代码中,lockTaken
的存在是为了防止这种特殊情况: **== ** Monitor.Enter
** ** 之前有其他动作抛出了异常,导致 ** ** Monitor.Enter
** ** 并未实际执行, Monitor.Exit
** ** 却会释放锁==。
22.2.2.2 TryEnter
Monitor.TryEnter
方法可以指定超时时间。使用分三种情况:
- 在指定时间内获得锁, 返回 true ,
- 在指定时间内未获得锁, 返回 false ,
- 未指定时间,且未获得锁, 立即返回 false 。
22.2.3 选择同步对象
同步对象(locker 对象)需遵循如下原则:
- 必须是 引用 类型
- 访问修饰符通常是 private ,也可以是 protected
- 通常是 字段 成员
除了我们定义的 object 对象外,还可以用如下对象作为 locker:
- this 实例
lock (this) { ... }
- 类型 实例
lock (typeof(Widget)) { ... }
Warn
上述锁定方式有一个缺点:无法封装锁逻辑,因此难以避免死锁或者长时间阻塞。而类型上的锁甚至可以跨越(同一进程中的)应用程序域的边界。
- Lambda 表达式或匿名方法中捕获的局部变量
C7.0 核心技术指南 第7版.pdf - p907 - C7.0 核心技术指南 第 7 版-P907-20240310161421
22.2.4 使用锁的时机
使用锁的基本原则是:若需要 修改共享字段 ,则需要在其周围加锁。即便对于最简单的情况(例如对某个字段进行赋值),也必须考虑进行同步。
锁会在变量前后创建内存栅障(memory barrier),内存栅障就像是操作变量的围栏,而编译器优化指令执行顺序(进行重排)、变量缓存是无法跨越这个围栏的。
22.2.5 锁与原子性
如果使用同一个锁对一组变量的读写操作进行保护,那么可以认为这些变量的读写操作是原子的(atomically)。
C7.0 核心技术指南 第7版.pdf - p909 - C7.0 核心技术指南 第 7 版-P909-20240310162357
22.2.6 嵌套锁
线程可以用嵌套(重入)的方式重复锁住同一个对象:
lock (locker)lock (locker)lock (locker){// Do something ...}
嵌套后,最内层方法 可以 正常执行,锁将在 最外 层释放。如下代码将 输出“Another method” :
static readonly object _locker = new object();static void Main() {lock (_locker) {AnotherMethod();}
}static void AnotherMethod(){lock (_locker) { Console.WriteLine ("Another method"); }
}
22.2.7 死锁
两个线程互相等待对方占用的资源,会使双方都无法继续执行,从而形成死锁。如下代码演示了死锁:
object locker1 = new object();
object locker2 = new object();new Thread (() => {lock (locker1) {Thread.Sleep (1000);lock (locker2) { } // Deadlock}
}).Start();lock (locker2) {Thread.Sleep (1000);lock (locker1) { } // Deadlock
}
22.2.8 性能
锁的操作是很快的:2015 年生产的计算机在没有出现竞争的情况下一般可以在 50 纳秒内获取或者释放锁。如果在竞争的情况下,则相应的上下文切换开销将增加到微秒级但即便如此,这个时间也可能小于线程实际的调度时间。
22.2.9 Mutex
Mutex
和 C# 的 lock
类似,但是它可以支持多个 进程 。换言之,Mutex
不但可以用于应用程序范围,还可以用于 计算机 范围。
在非竞争的情况下获得或者释放 Mutex
需要大约一微秒的时间,大概比 lock
要慢 20 倍。
C7.0 核心技术指南 第7版.pdf - p912 - C7.0 核心技术指南 第 7 版-P912-20240310165017
Mutex
常用于防止程序多开,实现方式如下:
static void Main() {// 通过给 Mutex 命名可以在计算机范围内使用该 Mutex。// Mutex 的名称在你的计算机和应用程序中应该是独一无二的(比如说用 URL)using (var mutex = new Mutex (false, "oreilly.com OneAtATimeDemo")) {// 如果 Mutex 在三秒内一直处于占用状态,退出程序。if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false)) {Console.WriteLine ("Another instance of the app is running. Bye!");return;}RunProgram();}
}static void RunProgram() {Console.WriteLine ("Running. Press Enter to exit");Console.ReadLine();
}
C7.0 核心技术指南 第7版.pdf - p912 - C7.0 核心技术指南 第 7 版-P912-20240310165718
22.3 锁和线程安全性
.NET 中自带的类型很少是线程安全的,保证线程安全性是开发者的责任。常用的线程安全实现方式有四:
- 牺牲粒度,将一大部分代码(甚至整个对象)都包裹在互斥锁中;
- 降低线程间交互,减少共享数据;
- 对于富文本客户端,可以在 UI 线程上访问共享状态(使用异步编程);
- 使用自动锁(
ContextBoundObject
基类 +Synchronization
特性)
22.3.1 线程安全和 .NET Framework 类型
锁可以将非线程安全的代码变为线程安全的代码。对于集合,我们在遍历前可以将它 拷贝到数组中 ,再进行遍历,避免枚举过程中一直占用互斥锁。
22.3.1.1 在线程安全的对象上使用锁
22.3.1.2 静态成员
在实现线程安全时需遵循如下原则:
- 静态成员 必须 是线程安全的
- 实例成员 可以是 线程安全的
静态成员的使用范围更广,更容易发生多线程交互、出现线程不安全的情况。
C7.0 核心技术指南 第7版.pdf - p915 - C7.0 核心技术指南 第 7 版-P915-20240310174937
22.3.1.3 只读线程安全性
C7.0 核心技术指南 第7版.pdf - p916 - C7.0 核心技术指南 第 7 版-P916-20240310175531
22.3.2 应用服务器的线程安全性
22.3.3 不可变对象
22.4 非互斥锁
22.4.1 信号量(Semaphore
)
Semaphore
就像一个俱乐部:它有特定的容量,还有门卫保护。一旦满员之后,就不允许其他人进入了,人们只能在外面排队。每当有人离开时,才准许另外一个人进入。
Semaphore
的构造器需要至少两个参数:俱乐部当前的 空闲容量 ,以及俱乐部的 总容量 。
Semaphore
有如下特点:
-
Semaphore
是线程无关的容量为 1 的
Semaphore
类似于Mutex
、lock
,但是它没有持有者这个概念,它是线程无关的。 任何 线程都可以调用Semaphore
的Release
方法。Mutex
和lock
则不然,只有 持有锁 的线程才能够释放锁。 -
它用于 限制并发性 ,防止太多线程同时执行特定的代码。
当我们发现 cpu 占用过高,或爬虫时不想过于频繁,可以通过这种方式限制线程的使用数量。
var inputs = Enumerable.Range(1, 10).ToArray(); var tasks = new List<Task<int>>(); // 限制最多有两个线程可以使用。 var sem = new SemaphoreSlim(2, 2);foreach (var input in inputs){tasks.Add(HeavyJob(input)); }await Task.WhenAll(tasks);var outputs = tasks.Select(x => x.Result).ToArray(); outputs.Dump();async Task<int> HeavyJob(int input){await sem.WaitAsync();await Task.Delay(1000);// 使用完需要释放。sem.Release();return input * input; }
22.4.2 读写锁
通常来说,一个对象(可以是文件、可以是集合)可以同时“读”,却不能同时“读+写”、“写+写”。读写锁便是为该场景服务的。
读写锁有两个:
-
ReaderWriterLockSlim
-
ReaderWriterLock
C7.0 核心技术指南 第7版.pdf - p919 - C7.0 核心技术指南 第 7 版-P919-20240310231325
它们有两种基本的锁:读锁和写锁:
- 写 锁是全局排它锁
- 读 锁可以兼容其他的 读 锁
ReaderWriterLockSlim
的使用
ReaderWriterLockSlim
定义了如下方法来获得、释放锁:
public void EnterReadLock();
public void ExitReadLock();
public void EnterWriteLock();
public void ExitWriteLock();
此外,每个 EnterXXX
方法都有相应的 TryEnterXXX
方法,可以像 Monitor.TryEnter
接受超时参数。
ReaderWriterLockSlim
还提供了若干属性用于监视锁的状态:
public bool IsReadLockHeld { get; }
public bool IsUpgradeableReadLockHeld { get; }
public bool IsWriteLockHeld { get; }
public int WaitingReadCount { get; }
public int WaitingUpgradeCount { get; }
public int WaitingWriteCount { get; }
public int RecursiveReadCount { get; }
public int RecursiveUpgradeCount { get; }
public int RecursiveWriteCount { get; }
以下示例演示了 ReaderWriterLockSlim
的用法。
- 读:三个线程将持续枚举列表中的元素。
- 写:两个线程每隔 100 毫秒生成一个随机数,并试图将该数字加入列表中。
static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
static List<int> _items = new List<int>();
static Random _rand = new Random();static void Main() {new Thread (Read).Start();new Thread (Read).Start();new Thread (Read).Start();new Thread (Write).Start ("A");new Thread (Write).Start ("B");
}static void Read() {while (true) {_rw.EnterReadLock();foreach (int i in _items) Thread.Sleep (10);_rw.ExitReadLock();}
}static void Write (object threadID) {while (true) {int newNumber = GetRandNum (100);_rw.EnterWriteLock();_items.Add (newNumber);_rw.ExitWriteLock();Console.WriteLine ("Thread " + threadID + " added " + newNumber);Thread.Sleep (100);}
}static int GetRandNum (int max) { lock (_rand) return _rand.Next(max); }
C7.0 核心技术指南 第7版.pdf - p921 - C7.0 核心技术指南 第 7 版-P921-20240311123626
ReaderWriterLock
ReaderWriterLock
中的锁方法名为 AcquireXXX
和 ReleaseXXX
。 AcquireXXX
方法需要传入超时时间,超时后将 抛出 ApplicationException
异常 。
22.4.2.1 可升级锁
以如下代码为例,读锁查询 → 写锁 Add
的中间,可能出现 其他线程将数据插入 的问题:
int newNumber = GetRandNum(100);
_rw.EnterReadLock();
if (!_items.Contains(newNumber))
{_rw.ExitReadLock();_rw.EnterWriteLock();_items.Add(newNumber);_rw.ExitWriteLock();Console.WriteLine("Thread " + threadID + " added " + newNumber);
}
为避免此问题,我们可以使用 UpgradeableReadLock
:
int newNumber = GetRandNum (100);
_rw.EnterUpgradeableReadLock();
if (!_items.Contains (newNumber))
{_rw.EnterWriteLock();_items.Add (newNumber);_rw.ExitWriteLock();Console.WriteLine ("Thread " + threadID + " added " + newNumber);
}
_rw.ExitUpgradeableReadLock();
UpgradeableReadLock
锁具有 排它性 ,一次只能有 一 个 UpgradeableReadLock
。但是在 UpgradeableReadLock
被占用时,其他 ReadLock
可以正常进入。
22.4.2.2 锁递归
通常,ReaderWriterLockSlim
禁止使用嵌套锁或者递归锁。因此,下面的操作会 抛出异常 :
var rw = new ReaderWriterLockSlim ();
rw.EnterReadLock();
rw.EnterReadLock();
rw.ExitReadLock();
rw.ExitReadLock();
可以在 构造器 中传入 LockRecursionPolicy.SupportsRecursion
,可以确保只有在真正需要时才支持递归锁。
递归锁的基本原则是:
- 写锁和可升级锁可以内嵌 三种锁
- 读锁仅能内嵌 读锁
var rw = new ReaderWriterLockSlim (LockRecursionPolicy.SupportsRecursion);
rw.EnterReadLock();
rw.EnterReadLock();
rw.ExitReadLock();
rw.ExitReadLock();rw.EnterReadLock();
rw.EnterWriteLock();
Console.WriteLine(rw.IsReadLockHeld); // True
Console.WriteLine(rw.IsWriteLockHeld); // True
rw.ExitWriteLock();
rw.ExitReadLock();
22.5 使用 EventWaitHandle 发送信号
EventWaitHandle(事件等待句柄)有三种实现:
-
AutoResetEvent
-
ManualResetEvent(Slim)
-
CountdownEvent
22.5.1 AutoResetEvent
AutoResetEvent
类似于闸机,一次仅允许 一 个线程通过(Set()
后仅能通过 1 个 WaitOne()
),通过后闸机 自动关闭 (WaitOne()
后自动 Reset()
)。创建 AutoResetEvent
方式有二:
-
使用
AutoResetEvent
的构造器var auto = new AutoResetEvent (false);
-
使用
EventWaitHandle
的构造器var auto = new EventWaitHandle (false, EventResetMode.AutoReset);
AutoResetEvent
使用方式如下:
static EventWaitHandle _waitHandle = new AutoResetEvent (false);static void Main() {new Thread (Waiter).Start();Thread.Sleep (1000); // Pause for a second..._waitHandle.Set(); // Wake up the Waiter.
}static void Waiter() {Console.WriteLine ("Waiting...");_waitHandle.WaitOne(); // Wait for notificationConsole.WriteLine ("Notified");
}
C7.0 核心技术指南 第7版.pdf - p925 - C7.0 核心技术指南 第 7 版-P925-20240311171723
AutoResetEvent
常用的方法有:
-
Set
开放闸机
-
WaitOne
等待闸机开放,使用后自动调用
Reset
。可以接收超时时间,在指定时间内没有收到信号,返回 false。 -
Reset
关闭闸机
双向信号
假设主线程需要向工作线程连续发送三次信号。如果主线程单纯地连续调用 Set
方法若干次,那么第二次或者第三次发送的信号就有可能 丢失 ,因为工作线程需要时间来处理每一次的信号。
我们可以 使用两个 AutoResetEvent
,主线程和子线程 互相 Set
、 WaitOne
:
C7.0 核心技术指南 第7版.pdf - p927 - C7.0 核心技术指南 第 7 版-P927-20240311173724
static EventWaitHandle _ready = new AutoResetEvent (false);
static EventWaitHandle _go = new AutoResetEvent (false);
static readonly object _locker = new object();
static string _message;static void Main() {new Thread (Work).Start();_ready.WaitOne(); // First wait until worker is readylock (_locker) _message = "ooo";_go.Set(); // Tell worker to go_ready.WaitOne();lock (_locker) _message = "ahhh"; // Give the worker another message_go.Set();_ready.WaitOne();lock (_locker) _message = null; // Signal the worker to exit_go.Set();
}static void Work() {while (true) {_ready.Set(); // Indicate that we're ready_go.WaitOne(); // Wait to be kicked off...lock (_locker) {if (_message == null) return; // Gracefully exitConsole.WriteLine (_message);}}
}
22.5.2 ManualResetEvent
ManualResetEvent
类似于红绿灯,调用 Set
方法亮绿灯,任意 WaitOne
线程均可通过;调用 Reset
方法亮红灯,WaitOne
线程将阻塞。
与 AutoResetEvent
相同,创建 ManualResetEvent
方式有二:
-
使用
ManualResetEvent
的构造器var manual1 = new ManualResetEvent (false);
-
使用
EventWaitHandle
的构造器var manual2 = new EventWaitHandle (false, EventResetMode.ManualReset);
C7.0 核心技术指南 第7版.pdf - p928 - C7.0 核心技术指南 第 7 版-P928-20240311175842
C7.0 核心技术指南 第7版.pdf - p928 - C7.0 核心技术指南 第 7 版-P928-20240311175917
22.5.3 CountdownEvent
CountdownEvent
类似于坐满即走的班车。通过 构造器 设置座位数量,调用 Signal
方法占用一个座位, Wait
会阻塞线程,直至 剩余座位为 0 。
使用方式如下:
static CountdownEvent _countdown = new CountdownEvent (3);static void Main()
{new Thread (SaySomething).Start ("I am thread 1");new Thread (SaySomething).Start ("I am thread 2");new Thread (SaySomething).Start ("I am thread 3");_countdown.Wait(); // Blocks until Signal has been called 3 timesConsole.WriteLine ("All threads have finished speaking!");
}static void SaySomething (object thing)
{Thread.Sleep (1000);Console.WriteLine (thing);_countdown.Signal();
}
CountdownEvent
常用的方法有:
-
AddCount
添加座位,如果座位已满(计数为 0),调用该方法将 抛出异常 。为了避免异常,可以使用它的
Try
方法。 -
Signal
占用座位
-
Wait
等待满员发车
-
Reset
清空乘客
22.5.4 创建跨进程的 EventWaitHandle
AutoResetEvent
和 ManualResetEvent
派生自 EventWaitHandle
,EventWaitHandle
支持跨进程访问。
通过向 构造器 传递 名称 ,可以在进程间共用该 EventWaitHandle
。使用方式如下:
var wh = new EventWaitHandle(false, EventResetMode.AutoReset, "MyCompany.MyApp.SomeName");
22.5.5 等待句柄和延续操作
线程池(ThreadPool
)的 RegisterWaitForSingleObject
方法支持 EventWaitHandle.Set
后,自动 运行回调方法 。该方法签名如下:
public static RegisteredWaitHandle RegisterWaitForSingleObject(WaitHandle waitObject, WaitOrTimerCallback callBack, object? state, int millisecondsTimeOutInterval, bool executeOnlyOnce)
当 WaitHandle
接到信号时(或者超时后),委托就会在一个线程池线程中执行。之后,还需要调用 Unregister
解除非托管的句柄和回调之间的关系:
static ManualResetEvent _starter = new ManualResetEvent (false);public static void Main() {RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject (_starter, Go, "Some Data", -1, true);Thread.Sleep (5000);Console.WriteLine ("Signaling worker...");_starter.Set();Console.ReadLine();reg.Unregister (_starter); // Clean up when we’re done.
}public static void Go (object data, bool timedOut) {Console.WriteLine ("Started - " + data);// Perform task...
}
22.5.6 将等待句柄转换为任务
ThreadPool.RegisterWaitForSingleObject
的使用较为不便,我们可以通过 TaskCompletionSource
、 ManualResetEventSlim
将该功能包装为 Task
:
public static Task<bool> ToTask (this WaitHandle waitHandle, int timeout = -1) {var tcs = new TaskCompletionSource<bool>();RegisteredWaitHandle token = null;var tokenReady = new ManualResetEventSlim();token = ThreadPool.RegisterWaitForSingleObject (waitHandle, (state, timedOut) => {tokenReady.Wait();tokenReady.Dispose();token.Unregister (waitHandle);tcs.SetResult (!timedOut); },null,timeout, true);tokenReady.Set();return tcs.Task;
}
此时我们可以在等待句柄上附加延续操作、进行 await、设置超时时间:
myWaitHandle.ToTask().ContinueWith(...)await myWaitHandle.ToTask();if(!await (myWaitHandle.ToTask(5000)))Console.WriteLine ("Timed out");
上述代码的
ManualResetEventSlim
用于避免 token.Unregister
发生在 token
赋值之前 。
22.5.7 WaitAny
、WaitAll
和 SignalAndWait
这三个方法是 WaitHandle
的静态方法,作用如下:
-
WaitAny
可以等待一组句柄中的任意一个句柄
-
WaitAll
等待所有给定的句柄
-
SignalAndWait
需传入两个
WaitHandle
参数(令参数名为wh1
和wh2
),该方法自动调用 wh1.Set
, wh2.WaitOne
Notice
WaitAll
和SignalAndWait
不支持在 STA 线程上运行。
Eureka
使用
WaitAll
时,其实可以考虑使用 22.5.3 CountdownEvent。
22.6 Barrier
类
Barrier
类称为“线程屏障”,它和 22.5.3 CountdownEvent 相似,却更像欹器,待水接满后(阻塞结束后),将 清空容器,继续接水 。它常用于汇合线程。以下代码将输出:“ 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 ”:
static Barrier _barrier = new Barrier (3);static void Main() {new Thread (Speak).Start();new Thread (Speak).Start();new Thread (Speak).Start();
}static void Speak() {for (int i = 0; i < 5; i++) {Console.Write (i + " ");_barrier.SignalAndWait();}
}
Barrier
对象还支持容器清空后 回调(post-phase) ,上述代码构造器改为如下方式,将输出“ 0 0 0 | 1 1 1 | 2 2 2 | 3 3 3 | 4 4 4 | ”:
static Barrier _barrier = new Barrier (3, barrier => Console.Write(" | "));
22.7 延迟初始化
22.7.1 Lazy<T>
该类实现了 延迟初始化 功能。当 Lazy
构造器传入 false,它是线程 不安全 的,如下两段代码等价:
class Foo {Expensive _expensive;public Expensive Expensive {get {if (_expensive == null) _expensive = new Expensive();return _expensive;}}
}
class Foo
{Lazy<Expensive> _expensive = new Lazy<Expensive> (() => new Expensive(), false);public Expensive Expensive { get { return _expensive.Value; } }
}
当 Lazy
构造器传入 true,它是线程 安全 的,如下两段代码等价:
class Foo {Expensive _expensive;readonly object _expenseLock = new object();public Expensive Expensive {get {lock (_expenseLock) {if (_expensive == null) _expensive = new Expensive();return _expensive;}}}
}
class Foo
{Lazy<Expensive> _expensive = new Lazy<Expensive> (() => new Expensive(), true);public Expensive Expensive { get { return _expensive.Value; } }
}
22.7.2 LazyInitializer
类
LazyInitializer
为静态类,与 Lazy<T>
工作方式相似,有以下不同点:
- 直接使用静态方法操作自定义类型的字段,避免引入间接层次;
- 允许多个线程同时实例化,最终会使用 第一个完成实例化的成员 。
用法如下:
class Foo {Expensive _expensive;public Expensive Expensive {get { LazyInitializer.EnsureInitialized (ref _expensive, () => new Expensive());return _expensive;}}
}
这是一种极致的优化方式,但很少使用。它也会带来相应的开销:
- 如果竞争实例化的线程数量大于内核数量,速度会变慢。
- 由于它执行了多余的初始化,因此潜在地浪费了 CPU 资源。
- 初始化逻辑必须是线程安全的(因此,如果
Expensive
的构造器在静态字段上执行写操作,它就不具备线程安全性)。- 如果初始化器实例化一个需要销毁(
disposal
的对象),则必须编写额外的逻辑才能将“多创建的”对象销毁。
22.8 线程本地存储
不同的线程可以使用线程本地存储用存放自己的数据。实现线程本地存储的方法有三种。
22.8.1 [ThreadStatic]
特性
可以在 静态 字段上附加 ThreadStatic
特性:
[ThreadStatic] static int _x;
这样,每一个线程都会得到一个 _x
的独立副本。
需要注意的是:
-
不支持 实例 字段
-
无法通过 字段初始化 器为它赋值
它只在调用静态构造器的线程上执行一次。如下代码将输出:“ 20 10 1 1 1 ”:
void Main() {new Thread (() => { Thread.Sleep(1000); Foo._x++; Foo._x.Dump(); }).Start();new Thread (() => { Thread.Sleep(2000); Foo._x++; Foo._x.Dump(); }).Start();new Thread (() => { Thread.Sleep(3000); Foo._x++; Foo._x.Dump(); }).Start();Foo._x.Dump();Foo._y.Dump(); }public class Foo {[ThreadStatic]public static int _x = 10;public static int _y = 10;static Foo() {_x = 20;} }
22.8.2 ThreadLocal<T>
类
ThreadLocal<T>
使用方式如下,_x
的默认值为 3 :
static ThreadLocal<int> _x = new ThreadLocal<int> (() => 3);
// 如下写成下面这种形式,第一个线程输出的是 6 而非 4,剩余线程仍输出4
// static ThreadLocal<int> _x = new ThreadLocal<int>(() => 3) { Value = 5 };
void Main() {new Thread (() => { Thread.Sleep(1000); _x.Value++; _x.Dump(); }).Start();new Thread (() => { Thread.Sleep(2000); _x.Value++; _x.Dump(); }).Start();new Thread (() => { Thread.Sleep(3000); _x.Value++; _x.Dump(); }).Start();
}
可以调用 _x
的 Value
属性来访问线程本地值。ThreadLocal
的值是延迟计算的:其中的工厂函数会在(每一个线程)第一次调用时计算实际的值。
Summary
可以看到,
ThreadStatic
和ThreadLocal
的区别在于, ThreadLocal
可以对参数进行初始化,而 ThreadStatic
只能使用参数的默认值。
使用 ThreadLocal<T>
定义线程安全的 Random
Random
类不是线程安全的,我们可以通过 ThreadLocal<T>
为每一个线程生成一个独立的 Random
对象,使其线程安全:
var localRandom = new ThreadLocal<Random> (()=> new Random(Guid.NewGuid().GetHashCode())
);
22.8.3 GetData
方法和 SetData
方法
Thread
类的 GetData
、SetData
为每一个线程开辟了一个空间(插槽),可以从中存取数据。使用前需获取 LocalDataStoreSlot
插槽对象,用法如下:
void Main() {var test = new Test();new Thread (() => { Thread.Sleep(1000); test.SecurityLevel++; test.SecurityLevel.Dump(); }).Start();new Thread (() => { Thread.Sleep(2000); test.SecurityLevel++; test.SecurityLevel.Dump(); }).Start();new Thread (() => { Thread.Sleep(3000); test.SecurityLevel++; test.SecurityLevel.Dump(); }).Start();
}class Test {LocalDataStoreSlot _secSlot = Thread.GetNamedDataSlot ("securityLevel");public int SecurityLevel {get {object data = Thread.GetData (_secSlot);return data == null ? 0 : (int) data; // null == uninitialized}set {Thread.SetData (_secSlot, value);}}
}
插槽对象可以用如下方法获取:
-
Thread.GetNamedDataSlot
获取 命名 插槽
-
Thread.AllocateDataSlot
获取 匿名 插槽
Thread.FreeNamedDataSlot
方法可以 释放所有线程中的命名插槽 ,前提是它们在线程中的引用已被垃圾回收。这确保了当线程需要特定数据插槽时,只要它保留了正确的 LocalDataStoreSlot
对象的引用,那么相应的数据插槽就不会丢失。
22.9 Interrupt
和 Abort
方法
Interrupt
用于强制释放处于阻塞状态的线程,并抛出 ThreadInterruptedException
异常。详见 Thread.Interrupt。
Abort
用于强行中止其他线程,并抛出 ThreadAbortException
异常。详见 Abort[弃用]。 ThreadAbortException
异常较为特殊,会在 catch 后 继续抛出(以中止线程) 。若要阻止该行为,可以在 catch 块中调用 Thread.ResetAbort
静态方法,它会将线程状态(ThreadState
)从 AbortRequested
恢复至 Running
。
如下代码将 catch 两 次异常:
void Func() {try {try {while (true) {Thread.Sleep(20);}}catch (ThreadAbortException ex) {ex.Dump("1");}}catch(ThreadAbortException ex){ex.Dump("2");}
}
如下代码将 catch 一 次异常:
void Func() {try {try {while (true) {Thread.Sleep(20);}}catch (ThreadAbortException ex) {Thread.CurrentThread.ThreadState.Dump();Thread.ResetAbort();Thread.CurrentThread.ThreadState.Dump();ex.Dump("1");}}catch(ThreadAbortException ex){ex.Dump("2");}
}
C7.0 核心技术指南 第7版.pdf - p939 - C7.0 核心技术指南 第 7 版-P939-20240313123340
22.9.1 Abort 的使用陷阱
C# 中的 finally
块无论如何都会执行,静态构造器也不会因 Abort
中止,这都保证了应用程序域的完整性。不过仍有例外,这种例外会导致被中止线程遭到污染,并可能进一步污染应用程序域,甚至污染进程。因此 Abort
不是一个取消执行的通用手段。
例如,假设类型的实例构造器获得了一个非托管资源(例如一个文件句柄),而该资源需要调用 Dispose
方法进行释放。如果一个线程在 构造器 结束前中止,则部分构造的对象将 无法销毁 ,从而导致非托管句柄的泄露。(如果该类型有终结器,则终结器仍然会执行,但也需等到 GC 捕获到该对象时才会执行。)。.NET Framework 中的许多基础类型(包括 FileStream
)都有这样的风险,因此 Abort
在大多数情况下都不适用。中止 .NET Framework 的代码是不安全的。
22.10 Suspend
和 Resume
方法
Suspend
和 Resume
用于 冻结 、 解冻 线程,详见 Thread.Suspend[弃用]和 Thread.Resume[弃用]。如果线程持有锁,用 Suspend
挂起线程很容易造成 死锁 。
它们的应用场景很少,不过可以用来追踪线程的 调用栈信息 :
StackTrace stackTrace = null;
targetThread.Suspend();
try { stackTrace = new StackTrace (targetThread, true); }
finally { targetThread.Resume(); }
上述代码可能造成死锁,解决方案之一是:如果指定线程在 200 毫秒后仍然处于挂起状态,则在另外一个线程中调用 Resume
方法。虽然这会导致调用栈跟踪信息失效,但肯定比造成应用程序死锁要好得多:
StackTrace stackTrace = null;
var ready = new ManualResetEventSlim();new Thread (() =>
{// Backstop to release thread in case of deadlock:ready.Set();Thread.Sleep (200);try { targetThread.Resume(); } catch { }
}).Start();ready.Wait();
targetThread.Suspend();
try { stackTrace = new StackTrace (targetThread, true); }
catch { /* Deadlock */ }
finally
{try { targetThread.Resume(); } catch { stackTrace = null; /* Deadlock */ }
}
22.11 定时器
.NET Framework 提供了四种定时器,两种定时器是通用 多 线程定时器:
-
System.Threading.Timer
-
System.Timers.Timer
另外两种则是特殊用途的 单 线程定时器:
-
System.Windows.Forms.Timer
(Windows Forms 应用的定时器) -
System.Windows.Threading.DispatcherTimer
(WPF 的定时器)
多线程定时器更加强大,定时精确,使用灵活;需要更新界面元素(UI)的简单任务来说,单线程定时器更加安全方便。
22.11.1 多线程定时器
System.Threading.Timer
System.Threading.Timer
是最简单的多线程定时器,通过构造器便可使用。在构造器中传入如下参数即可启用:
-
TimerCallback
委托 - 委托参数 (可选)
- 时间间隔 (可选)
使用完成后通过 Timer.Dispose
停止。如下定时器 5s 后启动,之后每隔 1s 输出一次 tick... :
Timer tmr = new Timer (Tick, "tick...", 5000, 1000);
Console.WriteLine ("Press Enter to stop");
Console.ReadLine();
tmr.Dispose(); // This both stops the timer and cleans up.static void Tick (object data) {Console.WriteLine (data);
}
在创建定时器之后仍然可以调用 Change
方法修改定时器的定时间隔。如果希望定时器只触发一次,则可以用 Timeout.Infinite
作为构造器的最后一个参数。
System.Timers.Timer
System.Timers.Timer
对 System.Threading.Timer
进行了简单的包装,提高了易用性。它的附加功能如下:
- 实现了
IComponent
接口,允许 嵌入到 Visual Studio 设计器的组件托盘中 。 - 提供了
Interval
属性替代Change
方法。 - 提供了
Elapsed
事件取代回调委托。 - 提供了
Enabled
属性来开始和停止计时器(默认值为 false)。 - 如果不习惯使用
Enabled
属性还可以使用 Start
和 Stop
方法。 - 提供了
AutoReset
标志,用于指示重复的事件(默认值为 true)。 - 提供了
SynchronizingObject
属性。可调用该对象的 Invoke
和 BeginInvoke
方法安全地调用 WPF 元素和 Windows Forms 控件的方法。
用法如下:
var tmr = new System.Timers.Timer(); // 无需参数
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed; // 使用事件而非委托
tmr.Start(); // 开始
Console.ReadLine();
tmr.Stop(); // 停止
Console.ReadLine();
tmr.Start(); // 重新开始
Console.ReadLine();
tmr.Dispose(); // 永久释放static void tmr_Elapsed (object sender, EventArgs e) {Console.WriteLine ("Tick");
}
该定时器有如下特点:
- 事件线程来自 线程池 ,事件每一次都可能在不同的线程上触发;
- 事件几乎能保证触发的时效性,即使上一次事件未执行完毕。因此事件处理函数必须是线程安全的。
C7.0 核心技术指南 第7版.pdf - p943 - C7.0 核心技术指南 第 7 版-P943-20240313174534
22.11.2 单线程定时器
单线程计时器有两个:
-
System.Windows.Forms.Timer
(Windows Forms 应用的定时器)-
System.Windows.Threading.DispatcherTimer
(WPF 的定时器)
两种定时器的成员和 System.Timers.Timer
的成员非常相似,不同的是它们将事件发送到消息循环中执行。它有如下好处:
- 可以忽略线程安全性。
- 如果前一次的 Tick 没有完成处理,则新的 Tick 事件 绝不会 触发。
- 可以无须调用
Control.BeginInvoke
或者 Dispatcher.BeginInvoke
方法,可以直接在Tick
事件处理代码中操作元素或控件。
同样的,Tick 事件处理器必须非常快地执行完毕,否则将会导致用户界面失去响应。
C7.0 核心技术指南 第7版.pdf - p943 - C7.0 核心技术指南 第 7 版-P943-20240313174719