第22章 高级线程处理

第22章 高级线程处理

22.1 同步概述

多线程我们常需要一些同步结构进行线程间通讯。同步结构可以分为三类:

  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​ 方法可以指定超时时间。使用分三种情况:

  1. 在指定时间内获得锁, 返回 true
  2. 在指定时间内未获得锁, 返回 false
  3. 未指定时间,且未获得锁, 立即返回 false

22.2.3 选择同步对象

同步对象(locker 对象)需遵循如下原则:

  1. 必须是 引用 类型
  2. 访问修饰符通常private ,也可以是 protected
  3. 通常是 字段 成员

除了我们定义的 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 中自带的类型很少是线程安全的,保证线程安全性是开发者的责任。常用的线程安全实现方式有四:

  1. 牺牲粒度,将一大部分代码(甚至整个对象)都包裹在互斥锁中;
  2. 降低线程间交互,减少共享数据;
  3. 对于富文本客户端,可以在 UI 线程上访问共享状态(使用异步编程);
  4. 使用自动锁(ContextBoundObject​ 基类 + Synchronization​ 特性)

22.3.1 线程安全和 .NET Framework 类型

锁可以将非线程安全的代码变为线程安全的代码。对于集合,我们在遍历前可以将它 拷贝到数组中 ,再进行遍历,避免枚举过程中一直占用互斥锁。

22.3.1.1 在线程安全的对象上使用锁

22.3.1.2 静态成员

在实现线程安全时需遵循如下原则:

  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​ 有如下特点:

  1. Semaphore​​ 是线程无关的

    容量为 1 的 Semaphore​​ 类似于 Mutex​​、lock​​,但是它没有持有者这个概念,它是线程无关的。 任何 线程都可以调用 Semaphore​​ 的 Release​​ 方法。Mutex​​ 和 lock​​ 则不然,只有 持有锁 的线程才能够释放锁。

  2. 它用于 限制并发性 ,防止太多线程同时执行特定的代码。

    当我们发现 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 ​的用法。

  1. 读:三个线程将持续枚举列表中的元素。
  2. 写:两个线程每隔 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​,可以确保只有在真正需要时才支持递归锁。

递归锁的基本原则是:

  1. 写锁可升级锁可以内嵌 三种锁
  2. 读锁仅能内嵌 读锁
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(事件等待句柄)有三种实现:

  1. AutoResetEvent
  2. ManualResetEvent(Slim)
  3. CountdownEvent

22.5.1 AutoResetEvent

AutoResetEvent​ 类似于闸机,一次仅允许 个线程通过(Set()​ 后仅能通过 1 个 WaitOne()​),通过后闸机 自动关闭WaitOne()​ 后自动 Reset()​)。创建 AutoResetEvent​ 方式有二:

  1. 使用 AutoResetEvent​ 的构造器

    var auto = new AutoResetEvent (false);
    
  2. 使用 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 notification
	Console.WriteLine ("Notified");
}

C7.0 核心技术指南 第7版.pdf - p925 - C7.0 核心技术指南 第 7 版-P925-20240311171723

AutoResetEvent​ 常用的方法有:

  1. Set

    开放闸机

  2. WaitOne

    等待闸机开放,使用后自动调用 Reset ​​。可以接收超时时间,在指定时间内没有收到信号,返回 false。

  3. Reset

    关闭闸机

双向信号

假设主线程需要向工作线程连续发送三次信号。如果主线程单纯地连续调用 Set ​方法若干次,那么第二次或者第三次发送的信号就有可能 丢失 ,因为工作线程需要时间来处理每一次的信号。

我们可以 使用两个 AutoResetEvent ​,主线程和子线程 互相 SetWaitOne ​:

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 ready
	lock (_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 exit
			Console.WriteLine (_message);
		}
	}
}

22.5.2 ManualResetEvent

ManualResetEvent​ 类似于红绿灯,调用 Set ​ 方法亮绿灯,任意 WaitOne​ 线程均可通过;调用 Reset ​ 方法亮红灯,WaitOne​ 线程将阻塞。

AutoResetEvent​ 相同,创建 ManualResetEvent​ 方式有二:

  1. 使用 ManualResetEvent​ 的构造器

    var manual1 = new ManualResetEvent (false);
    
  2. 使用 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 times
	Console.WriteLine ("All threads have finished speaking!");
}

static void SaySomething (object thing)
{
	Thread.Sleep (1000);
	Console.WriteLine (thing);
	_countdown.Signal();
}

CountdownEvent​ 常用的方法有:

  1. AddCount

    添加座位,如果座位已满(计数为 0),调用该方法将 抛出异常 。为了避免异常,可以使用它的 Try​ 方法。

  2. Signal

    占用座位

  3. Wait

    等待满员发车

  4. 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​ 的静态方法,作用如下:

  1. WaitAny

    可以等待一组句柄中的任意一个句柄

  2. WaitAll

    等待所有给定的句柄

  3. 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>​ 工作方式相似,有以下不同点:

  1. 直接使用静态方法操作自定义类型的字段,避免引入间接层次
  2. 允许多个线程同时实例化,最终会使用 第一个完成实例化的成员

用法如下:

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 ​的独立副本。

需要注意的是:

  1. 不支持 实例 字段

  2. 无法通过 字段初始化 器为它赋值

    它只在调用静态构造器的线程上执行一次。如下代码将输出:“ 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);
		}
	}
}

插槽对象可以用如下方法获取:

  1. Thread.GetNamedDataSlot

    获取 命名 插槽

  2. 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​ 是最简单的多线程定时器,通过构造器便可使用。在构造器中传入如下参数即可启用:

  1. TimerCallback 委托
  2. 委托参数 (可选)
  3. 时间间隔 (可选)

使用完成后通过 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");
}

该定时器有如下特点:

  1. 事件线程来自 线程池 ,事件每一次都可能在不同的线程上触发;
  2. 事件几乎能保证触发的时效性,即使上一次事件未执行完毕。因此事件处理函数必须是线程安全的。

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 ​ 的成员非常相似,不同的是它们将事件发送到消息循环中执行。它有如下好处:

  1. 可以忽略线程安全性。
  2. 如果前一次的 Tick 没有完成处理,则新的 Tick 事件 绝不会 触发。
  3. 可以无须调用 Control.BeginInvoke ​ 或者 Dispatcher.BeginInvoke ​ 方法,可以直接在 Tick​ 事件处理代码中操作元素或控件。

同样的,Tick 事件处理器必须非常快地执行完毕,否则将会导致用户界面失去响应。

C7.0 核心技术指南 第7版.pdf - p943 - C7.0 核心技术指南 第 7 版-P943-20240313174719

posted @   hihaojie  阅读(7)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示