C# 多线程

文章笔记来源:https://www.bilibili.com/video/BV1Zf4y117fs/?spm_id_from=333.999.0.0&vd_source=5b692e5de6bc40107b035f9a6cea705a

什么是线程 Thread

  • 线程是一个可执行路径,它可以独立于其它线程执行。
  • 每个线程都在操作系统的进程(Process)内执行,而操作系统进程提供了程序运行的独立环境。
  • 单线程应用,在进程的独立环境里只跑一个线程,所以该线程拥有独占权
  • 多线程应用,单个进程中会跑多个线程,它们会共享当前的执行环境(尤其是内存)

image.png

线程的一些属性

  • 线程一旦开始执行,lsAlive就是true,线程结柬就变成false。
  • 线程结束的条件就是:线程构造函数传入的委托结束了执行。
  • 线程一旦结束,就无法再重启。
  • 每个线程都有个Name属性,通常用于调试。
    • 线程Name只能设置一次,以后更改会抛出异常。
  • 静态的Thread.CurrentThread 属性,会返回当先执行的线程

Join and Sleep

  • 调用Join方法,就可以等待另一个线程结束。
  • 添加超时
    • 调用Join 的时候,可以设置一个超时,用毫秒或者TimeSpan都可以。
      • 如果返回true,那就是线程结束了;如果超时了,就返回false。
  • Thread.Sleep()方法会暂停当前的线程,并等一段时间。
  • 注意:
    • Thread.Sleep(0)这样调用会导致线程立即放弃本身当前的时间片,自动将CPU移交给其他线程
    • Thread.Yield()做同样的事情,但是它只会把执行交给同一处理器上的其它线程
    • 当等待 Sleep 或Join的时候,线程处于阻塞的状态。

image.png

阻塞 Blocking

  • 如果线程的执行由于某种原因导致暂定,那么就认为该线程被阻塞了。
    • 例如在Sleep或者通过Join 等待其他线程结束。
  • 被阻塞的线程会立即将其处理器的时间片生成给其它线程,从此就不再消耗处理器时间,直到满足其阻塞条件为止。
  • 可以通过ThreadState这个属性来判断线程是否处于被阻塞的状态:

bool blocked = (someThread.ThreadState & ThreadState.WaitsleepJoin) != 0;

ThreadState

  • ThreadState是一个flags enum,通过按位的形式,可以合并数据的选项。image.png
  • 状态转换过程image.png
  • 但是它大部分的枚举值都没什么用,下面的代码将ThreadState 剥离为四个最有用的值之一:Unstarted、Running、WaitSleepJoin和 Stopped
  • image.png

解除阻塞 Unblocking

  • 当遇到下列四种情况的时候,就会解除阻塞:
    • 阻塞条件被满足
    • 操作超时(如果设置超时的话)
    • 通过Thread.Interrupt()进行打断
    • 通过Thread.Abort()进行中止

上下文切换

  • 当线程阳塞或解除阻塞时,操作系统将执行上下文切换。这会产生少量开销,通常为1或2微秒。

l/O-bound vs Compute-bound(或 CPU-Bound )

  • 一个花费大部分时间等待某事发生的操作称为I/O-bound
    • IO绑定操作通常涉及输入或输出,但这不是硬性要求:Thread.Sleep()也被视为IO-bound
  • 相反,一个花费大部分时间执行CPU密集型工作的操作称为Compute-bound。

阻塞Blocking vs 忙等待(自旋)Spinning

  • IO-bound操作的工作方式有两种
    • 在当前线程上同步的等待 Console.ReadLine(),Thread.Sleep(),Thread.Join()...
    • 异步的操作,在稍后操作完成时触发一个回调动作。
  • 同步等待的I/O-bound操作将大部分时间花在阻塞线程上。
  • 它们也可以周期性的在一个循环里进行 ”打转(自旋)“
    while (DateTime.Now < nextStartTime);
  • 在忙等待和阻塞方面有一些细微差别。
    • 首先,如果您希望条件很快得到满足(可能在几微秒之内),则短暂自旋可能会很有效,因为它避免了上下文切换的开销和延迟。
      • .NET Framework提供了特殊的方法和类来提供帮助SpinLock和 SpinWait.
    • 其次,阻塞也不是零成本。这是因为每个线程在生存周期会占用大约1MB的内存,并会给CLR和操作系统带来持续的管理开销。
      • 因此,在需要处理成百上千个并发操作的大量 IO-bound程序的上下文中,阻塞可能会很麻烦
      • 所以,此类程序需要使用基于回调的方法,在等待时完全撤消其线程。

什么是线程安全?

本地Local vs 共享的状态Shared State

Local本地独立

  • CLR为每个线程分配自己的内存栈(Stack) ,以便使本地变量保持独立。

Shared共享

  • 如果多个线程都引用到同一个对象的实例,那么它们就共享了数据。
  • 被Lambda表达式或匿名委托所捕获的本地变量,会被编译器转化为字段( field ) ,所以也会被共享。
  • 静态字段(field)也会在线程间共享数据。
internal class Program {
    static bool _done;
    static void Main(string[] args) {
        new Thread(Do).Start();
        Do();
    }

    static void Do() {
        if(!_done) {
            _done = true;
            Console.WriteLine("Done!");
        }
    } 
}

线程安全 Thread Safety

  • 后三个例子就引出了线程安全这个关键概念(或者说缺乏线程安全)
  • 上述例子的输出实际上是无法确定的:
    • 有可能(理论上)“Done”会被打印两次。
    • 如果交换Go方法里语句的顺序,那么“Done”被打印两次的几率会大大增加
    • 因为一个线程可能正在评估if,而另外一个线程在执行WriteLine语句,它还没来得及把done 设为true。

image.png

锁定与线程安全简介

  • 在读取和写入共享数据的时候,通过使用一个互斥锁( exclusive lock ),就可以修复前面例子的问题。
  • C#使用lock语句来加锁
  • 当两个线程同时竞争一个锁的时候(锁可以基于任何引用类型对象),一个线程会等待或阻塞,直到锁变成可用状态。
  • 在多线程上下文中,以这种方式避免不确定性的代码就叫做线程安全。
  • Lock 不是线程安全的银弹,很容易忘记对字段加锁,lock也会引起一些问题(死锁)
internal class Program {
    static bool _done;
    static readonly object _lock = new object();
    
    static void Main(string[] args) {
        new Thread(Do).Start();
        Do();
    }

    static void Do() {
        lock (_lock) {
            if (!_done) {
                Console.WriteLine("Done!");
                Thread.Sleep(100);
                _done = true;
            }
        }
    } 
}

向线程传递数据

  • 如果你想往线程的启动方法里传递参数,最简单的方式是使用lambda表达式,在里面使用参数调用方法。(例子lambda )
    • 使用Lambda表达式可以很简单的给Thread传递参数。但是线程开始后,可能会不小心修改了被捕获的变量,这要多加注意。
  • 甚至可以把整个逻辑都放在lambda里面。(例子multi-lambda )
internal class Program {
    static bool _done;

    static readonly object _lock = new object();
    static void Main(string[] args) {
        new Thread(() => {
            PrintThing("Hello,World!");
        }).Start();

        new Thread(() => {
            Console.WriteLine("Hello World!");
        }).Start();

        //注意C#3.0 之前在Start里传递参数,此时thing 的类型必须为object
        new Thread(PrintThing).Start("Hello World!");
    }

    static void PrintThing(object thing) {
        Console.WriteLine("信息:" + thing);
    }
}

异常处理

  • 创建线程时在作用范围内的 trylcatch/finally块,在线程开始执行后就与线程无关了。(可以在委托方法里使用try、catch)
  • 在WPF、WinForm里,可以订阅全局异常处理事件:
    • Application.DispatcherUnhandledException
    • Application.ThreadException
    • 在通过消息循环调用的程序的任何部分发生未处理的异常(这相当于应用程序处于活动状态时在主线程上运行的所有代码)后,将触发这些异常。
    • 但是非U线程上的未处理异常,并不会触发它。
  • 而任何线程有任何未处理的异常都会触发 AppDomain.CurrentDomain.UnhandledException
internal class Program {
    static bool _done;

    static readonly object _lock = new object();
    static void Main(string[] args) {
        new Thread(DoThrow).Start();
    }

    static void DoThrow() {
        try {
            throw null;
        } catch {
            Console.WriteLine("发生异常");
        }
    }
}

前台线程 vs 后台线程

  • 默认情况下,你手动创建的线程就是前台线程。

  • 只要有前台线程在运行,那么应用程序就会一直处于活动状态。

    • 但是后台线程却不行。
  • 一旦所有的前台线程停止,那么应用程序就停止了

    • 任何的后台线程也会突然终止。
  • 注意:线程的前台、后台状态与它的优先级无关(所分配的执行时间)

  • 可以通过 IsBackground 属性判断线程是否是后台线程。

  • 进程以这种形式终止的时候,后台线程执行栈中的finally 块就不会被执行了。

    • 如果想让它执行,可以在退出程序时使用Join来等待后台线程(如果是你自己创建的线程),或者使用signal construct,如果是线程池...
  • 应用程序无法正常退出的一个常见原因是还有活跃的前台线程。

线程优先级

  • 线程的优先级(Thread的Priority属性)决定了相对于操作系统中其它活跃线程所占的执行时间。
  • 优先级分为:
    • enum ThreadPriority

提高线程优先级

  • 提升线程优先级的时候需特别注意,因为它可能“饿死”其它线程。
  • 如果想让某线程(Thread)的优先级比其它进程(Process)中的线程(Thread)高,那就必须提升进程(Process)的优先级
    • 使用System.Diagnostics下的Process类。
using (Process p = Process.GetCurrentProcess())
p.Priorityclass = ProcessPriorityclass.High;
  • 这可以很好地用于只做少量工作且需要较低延迟的非UI进程。
  • 对于需要大量计算的应用程序(尤其是有UI的应用程序),提高进程优先级可能会使其他进程饿死,从而降低整个计算机的速度。

信号 Signaling

  • 有时,你需要让某线程一直处于等待的状态,直至接收到其它线程发来的通知。这就叫做signaling(发送信号)。
  • 最简单的信号结构就是 ManualResetEvent
    • 调用它上面的WaitOne方法会阻塞当前的线程,直到另一个线程通过调用Set方法来开启信号。
static void Main(string[] args) {
    var signal = new ManualResetEvent(false);

    new Thread(() => {
        Console.WriteLine("等待信号。。。");
        signal.WaitOne();//收到Set()发出的信号之前,停在这里
        signal.Dispose();
        Console.WriteLine("收到信号。");
    }).Start();

    Thread.Sleep(3000);//这里等待3s,Main线程再发送信号Set()
    signal.Set();
}
  • 调用完Set之后,信号会于“打开”的状态。可以通过调用Reset方法将其再次关闭

富客户端应用程序的线程

  • 在WPF,UWP,WinForm等类型的程序种,如果在主线程执行耗时的操作,就会导致整个程序无响应。因为主线程同时还需要处理消息循环,而渲染和鼠标键盘事件处理等工作都是消息循环来执行的。

  • 针对这种耗时的操作,一种流行的做法是启用一个worker 线程。

    • 执行完操作后,再更新到UI
  • 富客户端应用的线程模型通常是:

    • UI元素和控件只能从创建它们的线程来进行访问(通常是主UI线程)
    • 当想从worker线程更新UI的时候,你必须把请求交给UI线程
  • 比较底层的实现是:

    • WPF,在元素的Dispatcher 对象上调用BeginInvoke 或Invoke。
    • WinForm,调用空间的 BeginInvoke 或 Invoke。
    • UWP,调用Dispatcher 对象上的 RunAsync 或Invoke。
  • 所有这些方法都接收一个委托。

  • BeginInvoke或RunAsync通过将委托排队到U线程的消息队列来执行工作。

  • Invoke执行相同的操作,但随后会进行阻塞,直到UI线程读取并处理消息。

    • 因此,Invoke 允许您从方法中获取返回值。
    • 如果不需要返回值,BeginInvoke/RunAsync更可取,因为它们不会阻塞调用方,也不会引入能锁的可能性

示例:
image.png

同步上下文 Synchronization Context

  • 在System.ComponentModel下有一个抽象类:SynchronizationContext,它使得Thread Marshaling得到泛化(把线程的所有权交给另一个线程)。
  • 针对移动、桌面(WPF,UWP,WinForms)等富客户端应用的API,它们都定义和实例化了SynchronizationContext的子类
    • 可以通过静态属性SynchronizationContext.Current来获得(当运行在UI线程时)
    • 捕获该属性让你可以在稍后的时候从worker线程向UI线程发送数据(例子)
    • 调用Post就相当于调用Dispatcher或 Control上面的 BeginInvoke方法
    • 还有一个Send方法,它等价于Invoke方法

线程池 ThreadPool

  • 当开始一个线程的时候,将花费几百微秒来组织类似以下的内容:
    • 一个新的局部变量栈(Stack )
  • 线程池就可以节省这种开销:
    • 通过预先创建一个可循环使用线程的池来减少这一开销。
  • 线程池对于高效的并行编程和细粒度并发是必不可少的
  • 它允许在不被线程启动的开销淹没的情况下运行短期操作

使用线程池线程需要注意的几点

  • 不可以设置池线程的Name

  • 池线程都是后台线程

  • 阻塞池线程可使性能降级

  • 你可以自由的更改池线程的优先级

    • 当它释放回池的时候优先级将还原为正常状态
  • 可以通过 Thread.CurrentThread.IsThreadPoolThread 属性来判断是否执行在池线程上

线程池中的整洁

  • 线程池提供了另一个功能,即确保临时超出计算-Bound的工作不会导致CPU超额订阅
  • CPU超额订阅:活跃的线程超过CPU的核数,操作系统就需要对线程进行时间切片
  • 超额订阅对性能影响很大,时间切片需要昂贵的上下文切换,并且可能使CPU缓存失效,而CPU缓存对于现代处理器的性能至关重要

CLR的策略

  • CLR通过对任务排队并对其启动进行节流限制来避免线程池中的超额订阅。
  • 它首先运行尽可能多的并发任务(只要还有CPU核),然后通过爬山算法调整并发级别,并在特定方向上不断调整工作负载.
    • 如果吞吐量提高,它将继续朝同一方向(否则将反转)。
  • 这确保它始终追随最佳性能曲线,即使面对计算机上竞争的进程活动时也是如此
  • 如果下面两点能够满足,那么CLR的策略将发挥出最佳效果:
    • 工作项大多是短时间运行的 (<250毫秒,或者理想情况下<100毫秒),因此CLR有很多机会进行测量和调整。
    • 大部分时间都被阻塞的工作项不会主宰线程池

image.png

Task

为什么要使用Task?

Thread的问题

  • 线程(Thread)是用来创建并发(concurrency)的一种低级别工具,它有一些限制,尤其是:
    • 虽然开始线程的时候可以方便的传入数据,但是当Join 的时候,很难从线程获得返回值。可能需要设置一些共享字段。
    • 如果操作抛出异常,捕获和传播该异常都很麻烦。
    • 无法告诉线程在结束时开始做另外的工作,你必须进行Join操作(在进程中阻塞当前的线程)
  • 很难使用较小的并发(concurrent)来组建大型的并发。
  • 导致了对手动同步的更大依赖以及随之而来的问题。

ThreadPool的问题
ThreadPool相比Thread来说具备了很多优势,但是ThreadPool却又存在一些使用上的不方便。
比如:

  1. ThreadPool不支持线程的取消、完成、失败通知等交互性操作;
  2. ThreadPool不支持线程执行的先后次序;

Task看起来像一个Thread,实际上,它是在ThreadPool的基础上进行的封装,Task的控制和扩展性很强,在线程的延续、阻塞、取消、超时等方面远胜于Thread和ThreadPool

Task 简介

  • Task类可以很好的解决上述问题
  • Task是一个相对高级的抽象:它代表了一个并发操作(concurrent)
    • 该操作可能由Thread支持,或不由Thread支持
  • Task是可组合的(可使用Continuation把它们串成链)
    • Tasks 可以使用线程池来减少启动延迟
    • 使用TaskCompletionSource,Tasks 可以利用回调的方式,在等待/O绑定操作时完全避免线程。

开始一个Task

  • Task类在System.Threading. Tasks命名空间下。
  • 开始一个Task最简单的办法就是使用Task.Run (.NET4.5,4.0的时候是Task.Factory.StartNew)这个静态方法
    • 传入一个Action委托即可
  • Task 默认使用线程池,也就是后台线程:
    • 当主线程结束时,你创建的所有tasks都会结束。
  • Task.Run返回一个Task 对象,可以使用它来监视其过程
  • 在Task.Run之后,我们没有调用Start,因为该方法创建的是“热”任务(hot task)
    • 可以通过Task 的构造函数创建“冷”任务(cold task),但是很少这样做。
  • 可以通过Task的Status属性来跟踪task的执行状态。
Task task=new Task(()=>{
	Console.WriteLine("开启一个线程");
});
task.Start();

Task.Run(()=>{
	Console.WriteLine("开启一个线程");
});

TaskFactory factory=new TaskFactory();
factory.StartNew(()=>{
	Console.WriteLine("开启一个线程");
});

TaskFactory factory2=Task.Factory();
factory2.StartNew(()=>{
	Console.WriteLine("开启一个线程");
});

等待 Wait

  • 调用task 的Wait方法会进行阻塞直到操作完成
    • 相当于调用thread 上的Join方法
  • Wait也可以让你指定一个超时时间和一个取消令牌来提前结束等待。

image.png

Long-running tasks长时间运行的任务

  • 默认情况下,CLR在线程池中运行Task,这非常适合短时间运行的Compute-Bound类工作。
  • 针对长时间运行的任务或者阻塞操作(例如前面的例子),你可以不采用线程池
  • 如果同时运行多个 long-running tasks(尤其是其中有处于阻塞状态的),那么性能将会受很大影响,这时有比TaskCreationOptions.LongRunning更好的办法:
    • 如果任务是IO-Bound,TaskCompletionSource和异步函数可以让你用回调(Coninuations)代替线程来实现并发。
    • 如果任务是Compute-Bound,生产者/消费者队列允许你对任务的并发性进行限流,避免把其他线程和进程饿死。

Task的线程等待和延续

Wait:针对单个Task的实例,可以task1.wait进行线程等待
WaitAny:线程列表中任何一个线程执行完毕即可执行(阻塞主线程)
WaitAll:线程列表中所有线程执行完毕方可执行(阻塞主线程)
WhenAny:与ContinueWith配合,线程列表中任何一个执行完毕,则继续ContinueWith中的任务(开启新线程,不阻塞主线程)
WhenAll:与ContinueWith配合,线程列表中所有线程执行完毕,则继续ContinueWith中的任务(开启新线程,不阻塞主线程)
ContinueWith:与WhenAny或WhenAll配合使用
ContinueWhenAny:等价于Task的WhenAny+ContinueWith
ContinueWhenAll:等价于Task的WhenAll+ContinueWith

List<Task> taskList = new List<Task>();
TaskFactory factory = Task.Factory;
taskList.Add(factory.StartNew(() => {
    Thread.Sleep(4000);
    MessageBox.Show($"{Task.CurrentId}");
}));
taskList.Add(factory.StartNew(() => {
    Thread.Sleep(6000);
    MessageBox.Show($"{Task.CurrentId}");
})); 
taskList.Add(factory.StartNew(() => {
    Thread.Sleep(8000);
    MessageBox.Show($"{Task.CurrentId}");
}));

Task.WaitAny(taskList.ToArray()); // 1-main-2-3 会阻塞主线程
Task.WaitAll(taskList.ToArray());   // 1-2-3-main 会阻塞主线程

Task.WhenAny(taskList.ToArray()).ContinueWith(t => {
    MessageBox.Show("1个已经执行完");
}); // main-1-"1个已经执行完"-2-3 不会阻塞主线程

Task.WhenAll(taskList.ToArray()).ContinueWith(t => {
    MessageBox.Show("全部已经执行完");
}); // main-1-2-3-"全部已经执行完" 不会阻塞主线程

factory.ContinueWhenAny(taskList.ToArray(), t => {
    MessageBox.Show("1个已经执行完");
}); // main-1-"1个已经执行完"-2-3 不会阻塞主线程  等价于Task的WhenAny+ContinueWith

factory.ContinueWhenAll(taskList.ToArray(), t => {
    MessageBox.Show("全部已经执行完");
}); // main-1-2-3-"全部已经执行完" 不会阻塞主线程   等价于Task的WhenAll+ContinueWith

MessageBox.Show("main");

Task取消

Task中有一个专门的类 CancellationTokenSource 来取消任务执行。

CancellationTokenSource tokenSource=new CancellationTokenSource();  
Task tsk = new Task(() => {
    // IsCancellationRequested默认值 false
    while (!tokenSource.IsCancellationRequested) {
        Thread.Sleep(1000);
        MessageBox.Show("Task想要取消");
    }
});
tsk.Start();
Thread.Sleep(5000);
tokenSource.Cancel(); // 请求取消任务,IsCancellationRequested会变成true
//tokenSource.CancelAfter(5000); // 实现5秒后自动取消任务
// 注册取消任务触发的回调函数,即任务被取消时注册的action会被执行
//tokenSource.Token.Register(() => {
    //MessageBox.Show("Task已经取消");
//});
MessageBox.Show("Task已经取消");
// 1s-Task想要取消-1s-Task想要取消-1s-Task想要取消-1s-Task想要取消-1s-Task想要取消-Task已经取消

Task返回值

  • Task 有一个泛型子类叫做Task,它允许发出一个返回值。
  • 使用Func委托或兼容的 Lambda表达式来调用Task.Run就可以得到Task
  • 随后,可以通过Result属性来获得返回的结果。
    • 如果这个task还没有完成操作,访问Result属性会阻塞该线程直到该task完成操作。
  • Task 可以看作是一种所谓的“未来/许诺”( future、promise),在它里面包裹着一个Result,在稍后的时候就会变得可用

image.png

Task异常

  • 与Thread不一样,Task可以很方便的传播异常
  • 如果你的task里面抛出了一个未处理的异常(故障),那么该异常就会重新被抛出给:
    • 调用了wait()的地方
    • 访问了Task的 Result属性的地方。
  • CLR将异常包裹在AggregateException里,以便在并行编程场景中发挥很好的作用。

image.png

  • 无需重新抛出异常,通过Task的 IsFaulted和 IsCanceled属性也可以检测出Task是否发生了故障:
    • 如果两个属性都返回false,那么没有错误发生。
    • 如果IsCanceled为 true,那就说明一个OperationCanceledException为该Task抛出了。
    • 如果lsFaulted为true,那就说明另一个类型的异常被抛出了,而Exception属性也将指明错误。

异常与“自治”的Task

  • 自治的,“设置完就不管了”的Task。就是指不通过调用Wait()方法、Result属性或continuation进行会合的任务。
  • 针对自治的Task,需要像 Thread 一样,显式的处理异常,避免发生“悄无声息的故障”
  • 自治Task上未处理的异常称为未观察到的异常。

未观察到的异常

  • 可以通过全局的 TaskScheduler.Unobserved TaskException来订阅未观察到的异常。
  • 关于什么是“未观察到的异常”,有一些细微的差别:
    • 使用超时进行等待的Task,如果在超时后发生故障,那么它将会产生一个“未观察到的异常”
    • 在Task发生故障后,如果访问Task的 Exception属性,那么该异常就被认为是“已观察到的”
posted @ 2023-05-29 20:23  不爱菠萝的菠萝君  阅读(427)  评论(0编辑  收藏  举报