C# 多线程学习笔记 - 3

[TOC]

一、基于事件的异步模式

  1. 基于事件的异步模式 (event-based asynchronous pattern) 提供了简单的方式,让类型提供多线程的能力而不需要显式启动线程。

    • 协作取消模型。
    • 工作线程完成时安全更新 UI 的能力。
    • 转发异常到完成事件。
  2. EAP 仅是一个模式,需要开发人员自己实现。

  3. EAP 一般会提供一组成员,在其内部管理工作线程,例如 WebClient 类型就使用的 EAP 模式进行设计。

    // 下载数据的同步版本。
    public byte[] DownloadData (Uri address);
    // 下载数据的异步版本。
    public void DownloadDataAsync (Uri address);
    // 下载数据的异步版本,支持传入 token 标识任务。
    public void DownloadDataAsync (Uri address, object userToken);
    // 完成时候的事件,当任务取消,出现异常或者更新 UI 操作都可以才该事件内部进行操作。
    public event DownloadDataCompletedEventHandler DownloadDataCompleted;
    
    public void CancelAsync (object userState);  // 取消一个操作
    public bool IsBusy { get; }                  // 指示是否仍在运行
    
  4. 通过 Task 可以很方便的实现 EAP 模式类似的功能。

二、BackgroundWorker

  1. BackgroundWorker 是一个通用的 EAP 实现,提供了下列功能。
    • 协作取消模型。
    • 工作线程完成时安全更新 UI 的能力。
    • 转发异常到完成事件。
    • 报告工作进度的协议。
  2. BackgroundWorker 使用线程池来创建线程,所以不应该在 BackgroundWorker 的线程上调用 Abort() 方法。

2.1 使用方法

  1. 实例化 BackgroundWorker 对象,并且挂接 DoWork 事件。

  2. 调用 RunWorkerAsync() 可以传递一个 object 参数,以上则是 BackgroundWorker 的最简使用方法。

  3. 可以为 BackgroundWorker 对象挂接 RunWorkerCompleted 事件,在该事件内部可以对工作线程执行后的异常与结果进行检查,并且可以直接在该事件内部安全地更新 UI 组件。

  4. 如果需要支持取消功能,则需要将 WorkerSupportsCancellation 属性置为 true。这样在 DoWork() 事件当中就可通过检查对象的 CancellationPending 属性来确定是否被取消,如果是则将 Cancel 置为 true 并结束工作事件。

  5. 调用 CancelAsync 来请求取消。

  6. 开发人员不一定需要在 CancellationPendingtrue 时才取消任务,随时可以通过将 Cancel 置为 true 来终止任务。

  7. 如果需要添加工作进度报告,则需要将 WorkerReportsProgress 属性置为 true,并在 DoWork 事件中周期性地调用 ReportProcess() 方法来报告工作进度。同时挂接 ProgressChanged 事件,在其内部可以安全地更新 UI 组件,例如设置进度条 Value 值。

  8. 下列代码即是上述功能的完整实现。

    class Program
    {
    	static void Main()
    	{
    		var backgroundTest = new BackgroundWorkTest();
    		backgroundTest.Run();
    		Console.ReadLine();
    	}
    }
    
    public class BackgroundWorkTest
    {
    	private readonly BackgroundWorker _bw = new BackgroundWorker();
    
    	public BackgroundWorkTest()
    	{
    		// 绑定工作事件
    		_bw.DoWork += BwOnDoWork;
    		
    		// 绑定工作完成事件
    		_bw.WorkerSupportsCancellation = true;
    		_bw.RunWorkerCompleted += BwOnRunWorkerCompleted;
    		
    		// 绑定工作进度更新事件
    		_bw.WorkerReportsProgress = true;
    		_bw.ProgressChanged += BwOnProgressChanged;
    	}
    
    	private void BwOnProgressChanged(object sender, ProgressChangedEventArgs e)
    	{
    		Console.WriteLine($"当前进度:{e.ProgressPercentage}%");
    	}
    
    	private void BwOnRunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    	{
    		if (e.Cancelled)
    		{
    			Console.WriteLine("任务已经被取消。");
    		}
    
    		if (e.Error != null)
    		{
    			Console.WriteLine("执行任务的过程中出现了异常。");
    		}
    		
    		// 在当前线程可以直接更新 UI 组件的数据
    		
    		Console.WriteLine($"执行完成的结果:{e.Result}");
    	}
    
    	public void Run()
    	{
    		_bw.RunWorkerAsync(10);
    	}
    
    	private void BwOnDoWork(object sender, DoWorkEventArgs e)
    	{
    		// 这里是工作线程进行执行的
    		
    		Console.WriteLine($"需要计算的数据值为:{e.Argument}");
    
    		for (int i = 0; i <= 100; i += 20)
    		{
    			if (_bw.CancellationPending)
    			{
    				e.Cancel = true;
    				return;
    			}
    			
    			_bw.ReportProgress(i);
    		}
    		
    		
    		// 传递完成的数据给完成事件
    		e.Result = 1510;
    	}
    }
    
  9. BackgroundWorker 不是密闭类,用户可以继承自 BackgroundWorker 类型,并重写其 DoWork() 方法以达到自己的需要。

三、线程的中断与中止

  1. 所有 阻塞 方法在解除阻塞的条件没有满足,并且其没有指定超时时间的情况下,会永久阻塞。

  2. 开发人员可以通过 Thread.Interrupt()Thread.Abort() 方法来解除阻塞。

  3. 在使用线程中断与中止方法的时候,应该十分谨慎,这可能会导致一些意想不到的情况发生。

  4. 为了演示上面所说的概念,可以编写如下代码进行测试。

    class Program
    {
    	static void Main()
    	{
    		var test = new ThreadInterrupt();
    		test.Run();
    		Console.ReadLine();
    	}
    }
    
    public class ThreadInterrupt
    {
    	public void Run()
    	{
    		var testThread = new Thread(WorkThread);
    		
    		testThread.Start();
            // 中断指定的线程
    		testThread.Interrupt();
    	}
    
    	private void WorkThread()
    	{
    		try
    		{
    			// 永远阻塞
    			Thread.Sleep(Timeout.Infinite);
    		}
    		catch (ThreadInterruptedException e)
    		{
    			Console.WriteLine("产生了中断异常.");
    		}
    		
    		Console.WriteLine("线程执行完成.");
    	}
    }
    

3.1 中断

  1. 在一个阻塞线程上调用 Thread.Interrupt() 方法,会导致该线程抛出 ThreadInterruptedException 异常,并且强制释放线程。
  2. 中断线程时,除非没有对 ThreadInterruptedException 进行处理,否则是不会导致阻塞线程结束的。
  3. 随意中断一个线程是十分危险的,我们可以通过信号构造或者取消构造。哪怕是使用 Thread.Abort() 来中止线程,都比中断线程更加安全。
  4. 因为随意中断线程会导致调用栈上面的任何框架,或者第三方的方法意外接收到中断。

3.2 中止

Thread.Abort() 方法在 .NET Core 当中无法使用,调用该方法会抛出 Thread abort is not supported on this platform. 错误。

  1. 在一个阻塞线程上调用 Thread.Abort() 方法,效果与中断相似,但会抛出一个 ThreadAbortException 异常。
  2. 该异常在 catch 块结束之后会被重新抛出。
  3. 未经处理的 ThreadAbortException 是仅有的两个不会导致应用程序关闭的异常之一。
  4. 中止与中断最大的不同是,中止操作会立即在执行的地方抛出异常。例如中止发生在 FileStream 的构造期间,可能会导致一个非托管文件句柄保持打开状态导致内存泄漏。

四、安全取消

  1. 与实现了 EAP 模式的 BackgroundWorker 类型一样,我们可以通过协作模式,使用一个标识来优雅地中止线程。

  2. 其核心思路就是封装一个取消标记,将其传入到线程当中,在线程执行时可以通过这个取消标记来优雅中止。

    class Program
    {
    	static void Main()
    	{
    		var test = new CancelTest();
    		test.Run();
    		Console.ReadLine();
    	}
    }
    
    public class CancelToken
    {
    	private readonly object _selfLocker = new object();
    	private bool _cancelRequest = false;
    
    	/// <summary>
    	/// 当前操作是否已经被取消。
    	/// </summary>
    	public bool IsCancellationRequested
    	{
    		get
    		{
    			lock (_selfLocker)
    			{
    				return _cancelRequest;
    			}
    		}
    	}
    	
    	/// <summary>
    	/// 取消操作。
    	/// </summary>
    	public void Cancel()
    	{
    		lock (_selfLocker)
    		{
    			_cancelRequest = true;
    		}
    	}
    
    	/// <summary>
    	/// 如果操作已经被取消,则抛出异常。
    	/// </summary>
    	public void ThrowIfCancellationRequested()
    	{
    		lock (_selfLocker)
    		{
    			if (_cancelRequest)
    			{
    				throw new OperationCanceledException("操作被取消.");
    			}
    		}
    	}
    }
    
    public class CancelTest
    {
    	public void Run()
    	{
    		var cancelToken = new CancelToken();
    		
    		var workThread = new Thread(() =>
    		{
    			try
    			{
    				Work(cancelToken);
    			}
    			catch (OperationCanceledException e)
    			{
    				Console.WriteLine("任务已经被取消。");
    			}
    		});
    		
    		workThread.Start();
    
    		Thread.Sleep(1000);
    		cancelToken.Cancel();
    	}
    
    	private void Work(CancelToken token)
    	{
    		// 模拟耗时操作
    		while (true)
    		{
    			token.ThrowIfCancellationRequested();
    			try
    			{
    				RealWork(token);
    			}
    			finally
    			{
    				// 清理资源
    			}
    		}
    	}
    
    	private void RealWork(CancelToken token)
    	{
    		token.ThrowIfCancellationRequested();
    		Console.WriteLine("我是真的在工作...");
    	}
    }
    

4.1 取消标记

  1. 在 .NET 提供了 CancellationTokenSourceCancellationToken 来简化取消操作。

  2. 如果需要使用这两个类,则只需要实例化一个 CancellationTokenSource 对象,并将其 Token 属性传递给支持取消的方法,在需要取消的使用调用 Source 的 Cancel() 即可。

    // 伪代码
    var cancelSource = new CancellationTokenSource();
    
    // 启动线程
    new Thread(() => work(cancelSource.Token)).Start();
    
    // Work 方法的定义
    void Work(CancellationToken cancelToken)
    {
        cancelToken.ThrowIfCancellationRequested();
    }
    
    // 需要取消的时候,调用 Cancel 方法。
    cancelSource.Cancel();
    

五、延迟初始化

  1. 延迟初始化的作用是缓解类型构造的开销,尤其是某个类型的构造开销很大的时候可以按需进行构造。

    // 原始代码
    public class Foo
    {
        public readonly Expensive Expensive = new Expensive();
    }
    
    public class Expensive
    {
        public Expensive()
        {
            // ... 构造开销极大
        }
    }
    
    // 按需构造
    public class LazyFoo
    {
        private Expensive _expensive;
        
        public Expensive Expensive
        {
            get
            {
                if(_expensive == null) _expensive = new Expensive();
            }
        }
    }
    
    // 按需构造的线程安全版本
    public class SafeLazyFoo
    {
        private Expensive _expensive;
        private readonly object _lazyLocker = new object();
        
        public Expensive Expensive
        {
            get
            {
                lock(_lazyLocker)
                {
                    if(_expensive == null)
                    {
                        _expensive = new Expensive();
                    }
                }
            }
        }
    }
    
  2. 在 .NET 4.0 之后提供了一个 Lazy<T> 类型,可以免去上面复杂的代码编写,并且也实现了双重锁定模式。

  3. 通过在创建 Lazy<T> 实例时传递不同的 bool 参数来决定是否创建线程安全的初始化模式,传递了 true 则是线程安全的,传递了 false 则不是线程安全的。

    public class LazyExpensive
    {
    
    }
    
    public class LazyTest
    {
        // 线程安全版本的延迟初始化对象。
        private Lazy<LazyExpensive> _lazyExpensive = new Lazy<LazyExpensive>(()=>new LazyExpensive(),true);
    
        public LazyExpensive LazyExpensive => _lazyExpensive.Value;
    }
    

5.1 LazyInitializer

  1. LazyInitializer 是一个静态类,基本与 Lazy<T> 相似,但是提供了一系列的静态方法,在某些极端情况下可以改善性能。

    public class LazyFactoryTest
    {
    	private LazyExpensive _lazyExpensive;
    	
    	// 双重锁定模式。
    	public LazyExpensive LazyExpensive
    	{
    		get
    		{
    			LazyInitializer.EnsureInitialized(ref _lazyExpensive, () => new LazyExpensive());
    			return _lazyExpensive;
    		}
    	}
    	
    }
    
  2. LazyInitializer 提供了一个竞争初始化的版本,这种在多核处理器(线程数与核心数相等)的情况下速度比双重锁定技术要快。

    volatile Expensive _expensive;
    public Expensive Expensive
    {
      get
      {
        if (_expensive == null)
        {
          var instance = new Expensive();
          Interlocked.CompareExchange (ref _expensive, instance, null);
        }
        return _expensive;
      }
    }
    

六、线程局部存储

  1. 某些数据不适合作为全局遍历和局部变量,但是在整个调用栈当中又需要进行共享,是与执行路径紧密相关的。所以这里来说,应该是在代码的执行路径当中是全局的,这里就可以通过线程来达到数据隔离的效果。例如线程 A 调用链是这样的 A() -> B() -> C()。

  2. 对静态字段增加 [ThreadStatic] ,这样每个线程就会拥有独立的副本,但仅适用于静态字段。

    [ThreadStatic] static int _x;
    
  3. .NET 提供了一个 ThreadLocal<T> 类型可以用于静态字段和实例字段的线程局部存储。

    // 静态字段存储
    static ThreadLocal<int> _x = new ThreadLocal<int>(() => 3);
    
    // 实例字段存储
    var localRandom = new ThreadLocal<Random>(() => new Random());
    
  4. ThreadLocal<T> 的值是 延迟初始化 的,第一次被使用的时候 才通过工厂进行初始化。

  5. 我们可以使用 Thread 提供的 Thread.GetData()Thread.SetData() 方法来将数据存储在线程数据槽当中。

  6. 同一个数据槽可以跨线程使用,而且它在不同的线程当中数据仍然是独立的。

  7. 通过 LocalDataStoreSolt 可以构建一个数据槽,通过 Thread.GetNamedDataSlot("securityLevel") 来获得一个命名槽,可以通过 Thread.FreeNameDataSlot("securityLevel") 来释放。

  8. 如果不需要命名槽,也可以通过 Thread.AllocateDataSlot() 来获得一个匿名槽。

    class Program
    {
    	static void Main()
    	{
    		var test = new ThreadSlotTest();
    		test.Run();
    		Console.ReadLine();
    	}
    }
    
    public class ThreadSlotTest
    {
    	// 创建一个命名槽。
    	private LocalDataStoreSlot _localDataStoreSlot = Thread.GetNamedDataSlot("命名槽");
    	// 创建一个匿名槽。
    	private LocalDataStoreSlot _anonymousDataStoreSlot = Thread.AllocateDataSlot();
    	
    	public void Run()
    	{
    		new Thread(NamedThreadWork).Start();
    		new Thread(NamedThreadWork).Start();
    		
    		new Thread(AnonymousThreadWork).Start();
    		new Thread(AnonymousThreadWork).Start();
    		
    		// 释放命名槽。
    		Thread.FreeNamedDataSlot("命名槽");
    	}
    
    	// 命名槽测试。
    	private void NamedThreadWork()
    	{
    		// 设置命名槽数据
    		Thread.SetData(_localDataStoreSlot,DateTime.UtcNow.Ticks);
    		
    		var data = Thread.GetData(_localDataStoreSlot);
    		Console.WriteLine($"命名槽数据:{data}");
    		
    		ContinueNamedThreadWork();
    	}
    
    	private void ContinueNamedThreadWork()
    	{
    		Console.WriteLine($"延续方法中命名槽的数据:{Thread.GetData(_localDataStoreSlot)}");
    	}
    
    	// 匿名槽测试。
    	private void AnonymousThreadWork()
    	{
    		// 设置匿名槽数据
    		Thread.SetData(_anonymousDataStoreSlot,DateTime.UtcNow.Ticks);
    		
    		var data = Thread.GetData(_anonymousDataStoreSlot);
    		Console.WriteLine($"匿名槽数据:{data}");
    
    		ContinueAnonymousThreadWork();
    	}
    	
    	private void ContinueAnonymousThreadWork()
    	{
    		Console.WriteLine($"延续方法中匿名槽的数据:{Thread.GetData(_anonymousDataStoreSlot)}");
    	}
    }
    

七、定时器

7.1 多线程定时器

  1. 多线程定时器使用线程池触发时间,也就意味着 Elapsed 事件可能会在不同线程当中触发。
  2. System.Threading.Timer 是最简单的多线程定时器,而 System.Timers.Timer 则是对于该计时器的封装。
  3. 多线程定时器的精度大概在 10 ~ 20 ms。

7.2 单线程定时器

  1. 单线程定时器依赖于 UI 模型的底层消息循环机制,所以其 Tick 事件总是在创建该定时器的线程触发。
  2. 单线程定时器关联的事件可以安全地操作 UI 组件。
  3. 精度比多线程定时器更低,而且更容易使 UI 失去响应。