C#多线程编程-必知必会

概要:使用C#发起多线程任务十分简单,本文旨在汇总多线程编程的注意事项,重点不在于如何发起多线程,主要内容如下:

  1. 控制线程并发数量
  2. 界定共享资源
  3. 加锁并控制锁范围
  4. 子线程异常处理
  5. 未完成任务取消.

    希望对小伙伴儿们有所帮助

01—控制线程并发数量

多线程以多任务并行的方式,加快业务处理速度,但如果线程数量超出了系统的承载能力,反倒会造成系统整体性能下降,如何合理地控制线程并发数量,是多线程开发的关键。
推荐采用信号量机制,可以在线程总数未知的情况下,有效地控制并发线程数量,并且以瀑布流的形式,连续执行后续线程,逻辑清晰可控,执行性能高效。

基础代码逻辑如下:

//semaphoreCount是设定的可并行运行的最大线程数量//taskCount是需要发起的线程的数量using (Semaphore semaphore = new Semaphore(semaphoreCount, semaphoreCount)){    var woker = new Worker();    Task[] tasks = new Task[taskCount];    for (int step = 0; step < taskCount; step++)       {                //获取一个信号量,如果所有信号量都已使用,则等待直到一个被释放                semaphore.WaitOne();                //获得信号量之后,才能发起子线程                tasks[step] = Task.Factory.StartNew((data) => { woker.Work(data); }, innerData)                                  .ContinueWith((task) =>                                                                    {                                                                            //线程完成,释放信号量                                                                            semaphore.Release();                                                                    });         }         //...}

简单来说,是由于分时操作系统,多任务之间存在线程上下文切换,有兴趣的同学可以尝试一下,一次性启动2000个以上线程,查看计算机的资源耗用情况,以便有更真切的体会。

02—界定共享资源

线程共享资源,一类是业务本身需要多个子线程共同处理的资源,另一类是从性能角度考虑,需要被多个子线程共享的资源。
以数据查询为例,数据库连接是一种昂贵的资源,如果每个子线程单独创建数据库连接,必然会造成浪费,多个线程共用一个数据库连接是更合理的选择,因此,数据库连接便是共享资源。
有兴趣的同学可以测试一下,同时启动50个以上线程,如果每个线程创建一个数据库连接,会造成数据库短时间内无法创建足够连接而报错。

03—加锁并控制锁范围

对共享资源进行访问时,需要加锁保护,防止并发错误。
对于业务本身处理的共享资源,加锁主要是防止数据处理错误;对于集合类型的共享资源,建议首选System.Collections.Concurrent 命名空间下的集合类型,以达到线程安全的目的;对于如数据库连接之类的资源,加锁是为了防止程序异常,如数据库连接、HttpClient对象,在一个请求处理完之前,是不能被其他线程访问的,因此需要加锁,确保串行访问是必须的。

对于锁对象,推荐的写法如下,至于是不是要加static ,要看具体业务场景,静态变量的作用域是整个应用程序,如果有两个以上请求同时到达,那么在访问到加锁代码块时,请求也是串行执行的,普通变量的作用域是当前对象,锁范围也是在当前对象内,请求间相互不影响。

readonly object locker = new object();
04—子线程异常处理
 
概括成一句话是:在明确异常处理要做什么的情况下,才进行异常处理,否则,让异常抛出,交由外层程序处理即可。参考我上一篇文章:异常处理,究竟是处理什么
多线程下异常处理的不同之处在于:子线程内的异常,不会直接抛出到主线程,而是保存在了Task对象的Exception属性中。因此,需要开发小伙伴判断线程状态,进行异常处理。

基础代码逻辑如下:

Task.Factory.StartNew((data) => { woker.Work(data); }, innerData)                        .ContinueWith((task) =>                        {                              //判断线程处理状态,如果执行失败,则抛出异常                              if (task.Status == TaskStatus.Faulted)                              {                                      throw task.Exception;                              }                  });

05—未完成任务取消

当某个子线程发生异常之后,取消后续相关线程的执行,符合绝大多数业务逻辑。
取消线程操作需要用到 CancellationTokenSource 类,线程启动时,注册“取消凭证(Token)”,当某个子线程发生异常后,调用CancellationTokenSource的Cancel()方法,通知相关线程取消操作。以后会写一篇CancellationToken的详细介绍。

基础代码逻辑如下:

//声明 CancellationTokenSourceusing (CancellationTokenSource cancellation = new CancellationTokenSource()){    Task[] tasks = new Task[taskCount];    for (int step = 0; step < steps; step++)    {        semaphore.WaitOne();        //注册cancellation.Token        tasks[step] = Task.Factory.StartNew((data) => { woker.Work(data); }, innerData, cancellation.Token)                                .ContinueWith((task) =>                                {                                    if (task.Status == TaskStatus.Faulted)                                    {                                        //通知取消任务                                        cancellation.Cancel(true);                                        throw task.Exception;                                    }                                    semaphore.Release();                                });    }}
有多线程开发经历的小伙伴,可以看一下自己的代码,是否有对以上几点的处理。以上内容均来自于我个人的经验总结,如有疏漏,欢迎小伙伴补充指正。
最后,说一下对于多线程的认识,了解二次元的小伙伴应该知道一个词:“结界”,线程与结界有很多相似之处,一个子线程就相当于一个结界,结界内外虽处于同一空间,但却属于不同的世界,结界阻断了结界内外的联系,但又可以相互作用,更多相似处,小伙伴们自己体会。