温故之.NET线程基础

这篇文章包含以下内容

  • 线程基础
  • 线程同步
  • 线程取消

线程基础

线程是操作系统向程序分配处理器时间的基本单位,在进程内多个线程可以同时执行代码。 .NET Framework 将操作系统进程进一步细分为轻型托管子进程,称为“应用域”(由 AppDomain 表示)。一个或多个托管线程(由 Thread 表示)可以在同一个托管进程内的一个或任意多个应用域中运行。

支持多任务的操作系统,同时执行多个进程中的多个线程。为此,它为有需求的线程之间分配可用的处理器时(时间片)。

当前正在执行的线程在时间片结束后暂停,将切换到另一个线程继续运行。期间,系统会保存被暂停的线程的线程上下文,并载入线程队列中下一个线程的线程上下文。

时间片长度具体视操作系统和处理器而定。由于每个时间片都很小,因此即使只有一个处理器,多个线程也可以同时执行(至少我们感觉上会是这样的)。多处理器系统也是如此,不过,可执行线程会在可用处理器之间进行分配

需要注意的点

  • 系统使用内存来保存进程、AppDomain 对象和线程所需的上下文信息。因此,可以创建的进程数、AppDomain 对象数和线程数受可用内存限制(一般我们不用担心这个问题)
  • 调度大量的线程会消耗非常多的处理器时间。如果线程太多,其中大多数都不会运行很快(因为线程之间会频繁的切换)。如果当前大多数线程都位于同一个进程中,那么其他进程中的线程被调度的频率也会变得很低(也就是我们所说得系统变慢)
  • 多个线程之间的资源同步较为复杂,可能会导致许多 Bug 出现,甚至可能发生死锁
  • 务必处理线程的异常。除了通过调用 Abort、卸载 AppDomainCLR或主机结束来结束线程外,其他未经处理的线程(包括后台线程)异常通常会终止进程。(虽然在 .NET 4 以后我们可能不需要过于在意这个问题,但有一个良好的编码习惯可使程序更加健壮)

使用多线程时我们应该考虑以下准则

  • 尽可能少的使用 Thread.Abort 来中止其他线程,因为我们不知道该线程已经走到了哪一步,强行中止则可能造成数据错误甚至丢失
  • 不应该使用 Thread.SuspendThread.Resume 同步多个线程的活动。而应该使用 MutexManualResetEventAutoResetEventMonitor等同步基元来实现同步
  • 不要将类型用作锁定对象。即应该避免 lock(typeof(X)) 这种写法,因为对于给定类型,每个应用域只有一个 System.Type 实例
  • 不应该使用 lock(this) 这种写法,这种写法极易造成死锁
  • 对于简单的状态更改,推荐使用 Interlocked 类的方法,而不是 lock 语句。虽然 lock 语句使用更普遍,但 Interlocked 类提升了原子操作的性能。其中,Interlocked.CompareExchange 方法,还可以用作任何引用类型的安全替换
  • 线程池线程(ThreadPool)是后台线程。从 .NET Framework 4 开始,进程的线程池的默认大小取决于若干因素,例如虚拟地址空间的大小。进程可以调用 GetMaxThreads 方法,来确定线程数,也可以使用 SetMaxThreads 方法来控制最大线程数

对于新开发的应用,推荐使用 TaskTask<T> 对象来进行多任务处理,它比 ThreadThreadPool 使用起来更加方便,且更加可控

TaskTask<T> 默认使用线程池线程来运行任务,仅当在创建 Task 时指定 TaskCreateOptions.LongRunning 时,它才会创建一个独立的 Thread 来运行任务,如下

/// 普通任务采用的方式
Task.Factory.StartNew(() => {
  // 逻辑代码
}, TaskCreationOptions.LongRunning);

/// 对于耗时比较长的任务,推荐的使用方式
Task.Factory.StartNew(() => {
  // 逻辑代码
}, TaskCreationOptions.LongRunning);
复制代码

对于耗时较短的任务,我们不应该指定 TaskCreationOptions.LongRunning,因为它会新开一个线程,增加了性能成本(创建、上下文切换)。而对于耗时较长的任务,我们就应该指定该选项,因为如果不指定,该任务会占用线程池中某一线程过多的时间,这会造成其他任务无法及时的得到处理(线程池里面的线程就那么几个,如果长时间被占用,其他任务就没法使用它)。

因此建议,耗时长的任务,指定 TaskCreationOptions.LongRunning 选项;耗时短的任务,不指定。

线程同步

CLI(公共语言基础)提供了以下几种策略:

  • 同步代码区域:即对某一代码块的同步。一般我们会用 lock 语句来实现(lock 语句是使用 Monitor.EnterMonitor.Exit 方法进行实现,并使用 try…catch…finally 块来确保锁已解除)
  • 手动同步:即可以使用 .NET Framework 类库提供的同步对象。如MonitorMutexSpinLockReaderWriterLockSemaphore等等。不过这些同步对象要比 lock 更加复杂,使用的时候需要保证它们能够被正确的释放
  • 同步上下文:可以使用 SynchronizationAttributeContextBoundObject 对象启用简单的自动同步。
  • System.Collections.Concurrent 命名空间中的集合类:这些类提供了内置的同步添加和删除操作,比如BlockingCollection<T>ConcurrentBag<T>ConcurrentDictionary<TKey,TValue>
部分同步方式的介绍

lock 语句

lock 关键字将代码块标记为互斥区域,方法是获取给定对象的互斥锁,执行语句,然后释放该锁。其内部实现方式是使用 Monitor.EnterMonitor.Exit 方法进行实现,并使用 try…catch…finally 块来确保锁已解除

下面我们给一个线程安全的单例的例子,示例如下

public class MysqlDatabase {
    private static MysqlDatabase database;
    /// 用于辅助创建对象的互斥锁对象
    private static readonly object DatabaseCreateLocker = new object();

    public static MysqlDatabase Database {
        get {
            // 利用双重检测、加锁的方式来创建单例
            if (database == null) {
                // 此处可确保只有一个线程进入以下代码块
                lock (DatabaseCreateLocker) {
                    // 进行 database == null 检查基于这种情况:
                    // 线程 A 首先进入了这个语句块,线程 B 访问的时候,会被阻塞,
                    // 直到线程 A 退出这个语句块
                    // 线程 A 退出后,线程 B 获得访问这个代码的权力,
                    // 这个时候 database 已经被 线程 A 初始化了,
                    // 因此我们需要判断该对象是否已经被创建,来保证 database 不会创建两次 
                    if (database == null) {
                        database = new MysqlDatabase();
                    }
                }
            }
            return database;
        }
    }
}
复制代码

关于 lock 的使用,建议定义 private 对象来进行锁定,或者定义 private static 对象变量(如上面示例所示)来保护所有实例所共有的数据

此外,还需要注意的是,在 lock 语句的代码块中不能使用 await 关键字

Mutex

Mutex 对象可实现对资源的独占访问。它可以跨应用域边界进行封送,可用于多个等待操作以及同步不同进程中的线程

对于Mutex使用,需要注意以下几点:

  • Mutex 只能由拥有它的线程(即创建它的线程)释放。如果释放 Mutex 的线程不是拥有它的线程,就会抛出 ApplicationException
  • Mutex 有两种类型:本地 Mutex 和命名系统 Mutex。本地 Mutex 仅存在于进程中,进程中引用本地 Mutex 对象的所有线程都可以使用本地 Mutex。而命名系统 Mutex 对象与同名的操作系统对象相关联,它在整个操作系统中都可见,并且可用于同步进程活动(可用于进程需要单例运行时,这通常会在程序入口进行检查,如果已经存在这样的Mutex对象,则会关闭当前进程)。

Interlocked

它用于以下场景:多个线程需要对某一个共享的变量进行访问。如果该变量位于共享内存中,则不同进程的线程都可以使用此机制。它可以提供性能非常高的同步,并且还可以用于生成更高级的同步机制,例如自旋锁。

有如下特点:

  • Interlocked 是具有原子性:即整个操作是不能由相同变量上的另一个互锁操作中断
  • CompareExchange 方法可以交换两个值,可使用此方法的重载来实现各种引用类型变量的互换

ReaderWriterLockSlim

借助ReaderWriterLockSlim 类,多个线程可以同时读取资源,但线程必须获得排他锁,才能对资源执行写入操作。即它允许多个线程同时处于读模式,但仅允许一个线程处于写模式

示例代码较长,具体可参考微软文档 ReaderWriterLockSlim类

Semaphore 和 SemaphoreSlim

Semaphore表示一个命名(系统范围内)或本地信号量。它是对 Win32 信号量对象的封装,Win32 信号量是计数信号量,其可用于控制对资源池的访问。而 SemaphoreSlim 类为一个轻量、快速的信号量,可在等待时间预计很短的情况下,用于在单个进程内的等待

信号量可用于生产者、消费者线程,其中一个线程始终增加信号量计数,另一个始终减少信号量计数

Barrier(拦截点,类似栅栏)

它是由用户定义的同步基元,以便于多个线程(称为“参与者”)可以分阶段同时处理算法。在达到代码中的拦截点之前,每个参与者将继续执行,拦截点表示工作阶段的末尾。单个参与者如果在所有参与者都到达拦截点之前到达,它就会被阻塞,直至所有参与者都到达为止。所有参与者都已达到屏障后,我们就可以继续执行后续操作。

举个例子,比如我们约了几个好友出去旅游,我们约定会合的地点为 A(拦截点),我们每个人(参与者)在到达 A 地点之前,会乘坐不同的交通工具(执行任务)。在所有人到达之前,已经到了的人都需要等待(阻塞)。所有人都到了之后,我们便可以坐上大巴车取旅游景点了(后续操作)。

Barrier 在算法中用得比较多,比如有些算法,可以拆分成很多小任务以便于充分利用计算机上的多个处理器,待小任务完成后,将每个任务的结果进行处理,而得出最终结果。

不过,大部分情况下,我们可以使用 TaskFactory.ContinueWhenAll 来替代 Barrier。但如果在各个任务间,需要共享资源,那在这种情况下,Barrier 是最好的选择

SpinWait

SpinWait 是一种轻量级、低级别的同步类型,它可以降低执行内核事件时上下文切换和内核转换产生的高成本。在多核计算机上,如果需要长时间保留资源,更高效的做法是,先让线程在用户模式下旋转几十或几百个周期(可以看成一个机器一直在空转,其实啥事都没做),再重试获取资源。如果资源在旋转后可用,便节省了几千个周期。如果资源仍不可用,那么也只花了几个周期,仍可以进入基于内核的等待

线程安全的集合

线程安全的集合包括以下几种

  • BlockingCollection<T>
  • ConcurrentDictionary<TKey,TValue>
  • ConcurrentQueue<T>
  • ConcurrentStack<T>
  • ConcurrentBag<T>

对于线程安全的集合

  • 添加:多个线程或任务可同时向集合添加对象,如果集合达到其指定的最大容量,则生产者线程将发生阻塞,直到集合中的某个对象被移除,有空余位置为止;
  • 移除:多个消费者可以同时移除对象,如果集合变空,则消费者线程将发生阻塞,直到生产者添加对象进集合,有可以使用的对象为止。

这儿举个生活中的例子,比如我们排队吃饭,餐馆里面的位置数量是固定的(集合的容量),假设顾客是生产者,我们进去吃饭,就需要占一个位置,如果里面没有位置了(集合已满),其他的客人就需要等待,直到有人吃完离开,下一个客人才能进去;假设服务员是消费者,他们负责收拾桌子,打理每个位置,如果没有客人来吃饭(集合为空),他们就没有碗筷需要收拾(等待),直到有人来吃饭(添加元素),他们才能继续收拾桌子。希望这个生活中的例子可以加深我们对 BlockingCollection 的理解

生产者线程可调用 CompleteAdding 来声明自己将不再向集合中添加元素。同时,消费者也可以通过检测 IsCompleted 属性来了解集合何时不再有新对象产生。示例代码如下:

using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;

public class App {
    public class Data {
    }
    public static void Main() {
        // 创建一个容量为 100 的集合
        BlockingCollection<Data> dataItems = new BlockingCollection<Data>(100);
        
        // 此处模拟消费者线程
        Task.Run(() =>
        {
            while (!dataItems.IsCompleted) {
                Data data = null;
                try {
                    // 当集合为空时,此处将会阻塞,直到有元素添加到集合为止
                    // 如果我们不希望一直等下去,我们可以使用 dataItems.TryTake 或它的重载方法来取元素
                    data = dataItems.Take();
                } catch (InvalidOperationException) {
                    // 抛出这个异常表示生产者线程通过 CompleteAdding 将 IsCompleted 设置为了 True,这个时候我们再去取元素就会抛出这个异常
                }

                if (data != null) {
                    // 业务逻辑
                }
            }
            // 如果能运行到这儿,表示生产者线程不再继续往集合添加元素(生成内容)了
            // 即生产者线程通过 CompleteAdding 将 IsCompleted 设置为了 True
            Console.WriteLine("没有元素了。。");
        });

        // 此处模拟生产者线程. 添加 110 个
        Task.Run(() =>
        {
            int count = 0;
            while (count < 110) {
                Data data = new Data();
                // 如果集合中的元素数量已经达到了 100 个,则此处将会阻塞,直到有人取走其中的某些元素位置
                // 如果我们不希望一直等下去,则可以调用 dataItems.TryAdd 或它的重载方法来添加元素
                dataItems.Add(data);
            }
            // 我们不再继续往集合添加元素(生成内容)了
            dataItems.CompleteAdding();
        });
    }
}
复制代码

一般情况下,个人建议采用 BlockingCollection 提供的非阻塞方法执行某些操作,比如使用 TryAddTryTake 等,这种方式可使程序的伸缩性更强、性能更好

在使用方式上 ConcurrentDictionary<TKey,TValue>ConcurrentQueue<T>ConcurrentStack<T>ConcurrentBag<T> 同上述代码差别不大,这个我们可以在写代码的时候,查看 Api 即可得知。 这里唯一不常见的是 ConcurrentBag<T>,它表示一个线程安全的 无序集合。当一个线程既是生产者,又是消费者的时候,使用它会更加高效(比其他线程安全集合都高效)

此外,BlockingCollection 可以与其他线程安全集合组合使用,如下

BlockingCollection<Data> blockCollections = new BlockingCollection<Data>(new ConcurrentStack<Data>(), 100);
复制代码

线程取消

.NET 4开始,我们可以以统一的方式去取消异步、长时间运行的任务,核心就是 CancellationTokenSource

实现步骤如下

  • 实例化 CancellationTokenSource 对象,此对象管理和发送取消通知给每个持有 CancellationTokenSource.Token 的线程
  • CancellationTokenSource.Token 传递给每个需要发送取消通知的线程
  • 为每个需要取消的线程实现接收到取消通知的代码,比如 CancellationTokenSource.Token.ThrowIfCancellationRequested()
  • 在外部调用 CancellationTokenSource.Cancel 以通知持有 CancellationTokenSource.Token 的线程结束自己的工作

切记不要忘记调用 CancellationTokenSource.Dispose 方法以释放相关资源

CancellationTokenSource 使用举例

示例代码如下(这儿采用的是通过轮询方式监听取消请求):

using System;
using System.Threading;
using System.Threading.Tasks;

public class App {
    public static void Main() {
        CancellationTokenSource cts = new CancellationTokenSource();
        Task.Run(() => {
            for (int i = 0; i < 100000; i++) {
                if (cts.Token.IsCancellationRequested) {
                    Console.WriteLine("取消操作触发于第 {0} 次循环...", i + 1);
                    break;
                }
                // 模拟任务处理
                Thread.SpinWait(500000);
            }
        }, cts.Token);
        
        Thread.Sleep(2500);

        // 取消任务
        Console.WriteLine("准备取消任务....");
        cts.Cancel();
        Console.WriteLine("取消任务完成....");
        Thread.Sleep(2500);
        cts.Dispose();

        Console.ReadLine();
    }
}
复制代码

输出如下

准备取消任务....
取消任务完成....
取消操作触发于第 104 次循环...
复制代码

监听任务取消命令

如果需要在循环或递归中监听取消命令,我们可以在需要时读取 IsCancellationRequested 属性的值。如果使用的是 Task 类型,可以使用 ThrowIfCancellationRequested 方法来检查属性并抛出异常,此方法优于手动抛出 OperationCanceledException。如果无需抛出异常,那我们直接检查 IsCancellationRequested 属性即可。示例代码见前面 “CancellationTokenSource 使用举例” 中的代码

有时,我们需要在任务被取消之后做一些其他的事情,这时候,我们就需要监听任务取消,代码如下

CancellationTokenSource cts = new CancellationTokenSource();
/// 这个方法有多个重载,具体使用哪种需要我们根据实际情况灵活选择
cts.Token.Register(() => {
    // 任务被取消,可用于清理资源等
});
复制代码

以上注册的监听操作,仅在 CancellationTokenSource.Token 被取消之后(即 CancellationTokenSource.Cancel()CancelAfter() 被调用之后),会被触发。但需要注意的是,这个时候,持有 CancellationTokenSource.Token 的线程并不一定都已经取消完了,有些可能还在做收尾工作

同时侦听多个取消命令

当需要同时侦听多个取消命令时(这种情况下,我们可以在其中任意一个命令发出取消请求时中止当前任务),使用CreateLinkedTokenSource 方法可以将两个令牌(CancellationTokenSource.Token)联接为一个令牌。这样我们就只需要使用链接之后的令牌了,示例代码如下

using System;
using System.Threading;

public class App {
    public static void Main() {
        CancellationTokenSource cts1 = new CancellationTokenSource();
        CancellationTokenSource cts2 = new CancellationTokenSource();

        using (CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts1.Token, cts2.Token)) {
            try {
                DoWorkInternal(linkedCts.Token);
            } catch (OperationCanceledException) {
                if (cts1.Token.IsCancellationRequested) {
                    Console.WriteLine("Token 1 Cancelled");
                } else if (cts2.Token.IsCancellationRequested) {
                    Console.WriteLine("Token 2 Cancelled.");
                    cts2.Token.ThrowIfCancellationRequested();
                }
            }
        }
        Console.ReadLine();

        cts1.Cancel();
        cts2.CancelAfter(2000);

        cts1.Dispose();
        cts2.Dispose();
    }
    private static void DoWorkInternal(CancellationToken token) {
        for (int i = 0; i < 1000; i++) {
            if (token.IsCancellationRequested) {
                // 抛出 OperationCanceledException 异常
                token.ThrowIfCancellationRequested();
            }

            // 模拟业务逻辑
            Thread.SpinWait(7500000);
            Console.Write("working... ");
        }
    }
}
复制代码

关于线程的一些基础就差不多这些了。关于这篇文章的一些代码,会和此文章一起推送出来


至此,本节内容讲解完毕。 欢迎关注公众号【嘿嘿的学习日记】,所有的文章,都会在公众号首发,Thank you~

转载于:https://juejin.im/post/5b32507b6fb9a00e35683f9c

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值