目录
Task.Run/Task.Factory.StartNew
Thread/ThreadPool/Task/Parallel
MVC/WebAPI中的Action中使用aync/await会提高请求的响应速度吗?
CPU寄存器
CPU寄存器是CPU内部用来存放数据的一些小型存储区域,暂时存放参与运算的数据和运算的结果以及一些CPU运行所需要的信息。
原生线程/托管线程
- 原生线程是操作系统管理的线程。CPU都有逻辑核心,每个核心都有多个寄存器,每一个逻辑核心都 会根据程序的寄存器从内存中去读取指令,单个逻辑核心在同一时间只能执行一段机器码,机器码就 是某个任务的处理内容,单个逻辑核心在同一时间只能执行一个线程,那么需要多个线程同时运行的 效果,就需要在这个逻辑核心上去轮流运行,并且每一个线程只能够运行一段很短的时间,然后就形 成了多线程的效果,其中轮流运行就是线程切换。
- 托管线程是基于原生线程创建的线程模型,是由.NET管理的线程。一个托管线程只能运行一个原生线 程。
主动切换/被动切换/上下文/上下文切换/线程调度/栈空间
- 主动切换是线程对应的任务主动要求暂停线程,比如说线程锁被其它线程获取了,这时候任务会主动 要求暂停线程,一直到锁释放才继续。如果读取文件时要等待磁盘的响应,那么这个任务也会要求去 暂停线程的运行。
- 被动切换是线程运行超过了一定时间后,操作系统会强制切换到下一个线程,这种强制切换的模式叫 做抢占模式,线程在抢占之前被运行的最大时间叫做时间片,这种抢占机制是基于硬件计时器来实现的。
- 上下文是CPU中各个寄存器的值。
- 上下文切换是线程之间的切换。本质上其实就是保存了当前寄存器的值到切换前的线程关联的上下文 数据中,然后从切换后的线程关联的上下文数据中读取值到寄存器中。
- 线程调度是负责安排待运行队列中的线程在逻辑核心上轮流执行,把等待不可用资源的线程放入到资 源对应的等待线程队列中,当资源可用后,再把等待的线程放入到线程队列。
- 栈空间是每一个原生线程都需要在内存中去使用的一块空间。栈空间主要用途就是用来保存函数使用 的数据,比如参数,本地变量,地址等等。栈空间最大地址称为栈底,最小地址称为栈边界,最后一 个被添加到栈的元素的所在地址,叫做栈顶。栈顶的地址是保存在栈寄存器中的也就是RSP寄存器, 如果是X86就是ESP。栈底和栈边界的地址保存在操作系统管理的原生线程对象中。由于栈寄存器属 于上下文的一部分,在上下文切换的时候这个上下文也会被同时切换。
- 栈空间存储自己的数据。
抢占模式/合作模式
抢占模式/合作模式
- 处于抢占模式的线程不能访问托管堆上面的对象,必须等待GC结束,切换到合作模式。
- 处于合作模式的线程可以自由访问托管堆上面的对象。
- 托管代码都是在合作模式下的,非托管代码是没有这个限制的。
- 一旦进入抢占模式,托管代码就会停止运行,非托管代码会继续运行但是只能做和.NET对象无关的处理。
为什么要有抢占模式和合作模式
- GC要找出所有存活的对象,清理没有引用的对象。负责执行扫描与清理对象的GC线程和负责分配对 象或者改变对象引用关系的其它线程同时运行,如果GC线程在清理一个没有引用的对象,那么正好这 个时候有一个线程去改变这个引用对象,就会发生冲突。
- 所以在GC运行的过程中,需要停止其它线程的运行,以保证对象间引用关系在GC的运行中不会改变。 但是以这种粗暴的方式来停止线程会带来很多问题。比如某个线程获取了某一个.NET运行时内部线程 锁后变成了停止状态,然后GC线程再处理过程中需要获取同一个锁,那么这时候就会导致死锁发 生。.NET为了让线程可以更安全的配合GC工作,就引用了抢占模式和合作模式。GC会根据需要去切 换到不同的模式。
- GC在停止其它线程运行的过程就是GC在切换某个线程到抢占模式。
- GC恢复某个线程运行的过程就是GC在切换某个线程到合作模式。
GC安全点
JIT编译器在生成托管函数汇编代码的时候会同时生成元数据,这个元数据中包含了GC信息,这个GC信息中包含了某个线程运行到某条指令时哪些位置有引用类型的对象,这些对象会作为根对象扫描。因为包含的引用类型对象位置在运行的过程中可能会不断的改变,如果要针对每一条汇编指令都生产一条GC信息会消耗很多的资源,所以JIT编译器在生产GC信息的时候是部分选择生成,这些被挑选的部分就是GC安全点。如果有一个托管函数的指令都生成了GC安全点,那么这个函数就称为完全可中断函数,如果不是每一条指令都生成GC安全点那么这个函数就称为部分可中断函数。
线程本地存储
是什么
- 线程本地存储是用于实现按线程隔离的线程本地变量。
- 对于同一个线程的本地变量,各个线程都有独立的值,修改的值只对修改的线程可见。
- 相当于让每个线程都开辟了一块内存空间,用来存储共享变量的一个副本,然后每个线程只需要去访 问和操作自己的共享变量副本就可以了,从而避免了多线程去竞争同一个共享资源。
ThreadStatic
ThreadStatic特性会让变量变成线程本地存储变量,每一个线程输出的结果不会被其它线程影响。
IOC容器里面的线程单例生命周期就是用ThreadStatic实现的。
ThreadLocal
- ThreadLocal是一个包装类和ThreadStatic是完全等价的。
- ThreadLocal中每个托管线程对象都关联一个TLB(Thread Local Block)表,这个表以AppDomain ID作为 索引保存TLM(Thread Local Module)表。
- TLB表以模块ID为索引保存了托管线程本地存储空间的开始地址。
- 当.NET运行时在加载一个程序集的时候,会枚举程序集中的模块和模块中的全局变量,然后根据其是 否为线程本地变量分为两个部分,非线程本地变量保存在AppDoamin高频堆中,线程本地变量就只计 算偏移值,存储空间会在首次访问的时候分配。
原子操作
是什么
- .NET中原子操作就是要么成功且状态改变,要么失败且状态不变。
- 比如用多线程处理不同文件,并且需要实时输出当前处理完成的数量合计,定义一个整数类型的全局 变量,每一个线程每处理完一个文件就递增一次,然后定时输出这个全局变量的值,这个递增的操作 必须是原子操作。
InterLocked
InterLocked类提供了执行原子操作的一些方法,这些方法接收的是引用参数也就是变量的内存地址,针对内存地址中的值进行原子操作。
线程锁/无锁算法
- 线程锁是把指定区域内的操作都变成原子操作,具有很高的通用性。线程锁只有获取锁和释 放锁,在 获取锁之后和释放锁之前的中间部分只保证只有一个线程运行。
- 无锁算法是不使用线程锁,而是修改操作的内容,以满足修改操作的条件。
自旋锁
是什么
- 自旋锁是基于原子操作实现的,基于一个数值来表示锁是否被获取,比如0表示未被获取,1表示已 被获取。获取锁的时候会先使用原子操作去设置它的数值为1,然后去检查修改前的数值是否为0,如 果为1表示获取成功,否则一直重试直到成功为止。释放锁的时候会设置它为0,其它正在获取锁的 线程再下一次重试时才会成功获取。
- 自旋锁指当一个线程再获取锁的时候,如果锁已经被其它线程获取,那么这个线程将循环等待,不断 的判断锁是不是能被成功获取,直到成功获取到了锁,才会退出循环。
- 如果已经明确的知道了被锁住的代码执行的时间非常短,就可以选择开销比较小的自旋锁。
SpinWait/SpinOnce
- Thread里面的SpinWait方法可以实现一个自旋锁。SpinWait通过调用汇编指令pause告诉CPU当前正 在自旋锁的循环中,当前的逻辑核心就可以按照时钟周期得到一个休息然后再去执行下一条指令,由 于pause指令休息时间在不同的CPU型号中是不同的,所以在.NET中自定义了一个标准的休息时间, 在程序启动时会计算需要多少次调用指令才能接近这个标准时间,SpinWait函数就会根据计 算出来的 次数去调用这个指令,这样就会让所有平台上的CPU休息时间是保持一致的。
- SpinWait类也可以实现一个自旋锁。调用SpinWait类的SpinOnce方法实现等待。如果在一定次数以内, 并且当前环境的逻辑核心数>1,那么底层还是调用的Thread.SpintWait方法。如果超过了一定次数,并 且当前环境的逻辑核心=1,那么就会交替使用Thread.Sleep(0)和Thread.Yield方法,再超过一定次数会 使用Thread.Sleep(1),让线程休眠1毫秒以避免频繁占用CPU资源,SpinWait类的这种特点可以解决自 选锁保护着代码长时间运行的问题,SpinWait类会告诉操作系统切换到其它线程上,让当前线程进入 休眠状态,这样就不会一直执行SpintWait方法而导致CPU在线程被抢占之前无法执行其它工作。 Sleep(0)实际上是调用SleepEx系统函数,Yield是调用SwitchToThread的系统函数,SwitchToThread函 数只会切换当前逻辑核心关联的待运行队列中的线程,是不会切换到其它逻辑核心上关联的线程,而 SleepEx是可以切换到任意逻辑核心关联的待运行队列中的线程,并且让当前线程在指定时间内无法重 新进入待运行队列。
- SpinLock类也可以实现自旋锁,这个类封装了锁状态的管理,以及调用SpinOnce的方法。
- 自旋锁保护的代码应该在较短的时间内执行完毕,如果代码长时间运行,就会造成某一个线程持有锁 的时间过长,就会导致其它等待获取锁的线程不断的重试并且占用逻辑核心,如果使用不当会造成CPU 的使用率非常高。
- 自旋锁是不公平的锁,不公平是因为等待时间最长的线程不一定会持有锁, 这就会造成线程饥饿。
- 自旋锁不会使线程状态发生切换,不会使线程进入阻塞状态,减少了不需要的上下文切换。
线程饥饿
多个线程同时访问一个同步资源,有些线程总是没机会获得锁。
互斥锁
是什么
自旋锁不适用于长时间运行的操作,应用场景比较有限。更通用的是操作系统提供的基于原子操作和线程调度结合实现的互斥锁。操作系统提供的互斥锁也有一个数值来表示这个锁是否已经被获取。与自旋锁不同的是互斥锁当获取锁失败的时候不重试,而是安排获取锁线程进入等待,把线程对象添加到这个锁关联的队列中,另一个线程锁释放的时候会检查这个队列是不是有线程对象,如果有就通知操作系统去唤醒这个线程。处于等待状态的线程是没有在运行的,即使这个锁长时间不释放也不会消耗CPU的资源。线程要从等待状态唤醒,唤醒之后还要调度运行,这个时候从等待状态到调度运行中间要花费一些时间,这个时间与自旋锁重试所需的比率是相当的,自旋锁如果是纳秒级,互斥锁就是毫秒级。
Mutex
- Mutex包装了操作系统提供的互斥锁。
- Mutex支持跨进程使用,可以防止程序多开,也可以用于保护多个进程共享的资源。
混合锁
是什么
- 由于互斥锁的性能比较低,.NET提供了更通用性能也更好的混合锁,而且任何引用类型的对象都可以 作为这个锁对象,也不需要事先创建某一个类型的实例,并且涉及非托管资源还会由.NET运行时自 动释放。
- 混合锁在获取锁失败后,也会向自旋锁一样重试一定的次数,超过一定的次数,进入等待状态。
- 混合锁的好处就是第一次获取锁失败以后,如果其它线程又马上释放了锁,那么当前这个线程在下一 轮重试时就可以立即获取成功,这样就不需要经过一个耗时的线程调度去处理。如果其它线程在短时 间内没有释放锁,某个线程获取锁的时候重试超过一定的次数,就会进入等待状态,避免消耗CPU 的资源。
- 混合锁适用于大部分的场景。
Monitor/Lock
Monitor和Lock是完全等价的。
为什么加锁用引用类型,并且定义为静态只读?
- 引用类型定义为静态是为了让对象常驻内存,定义为只读readonly是为了让引用类型在声明时就完成 初始化,并且保证对象的不可变性,这样在高并发的场景下,多线程在任何时候都看到的是同一个对 象。定义为私有成员保证该引用类型不会受到其它地方的影响。
- 定义为引用类型还有一个原因就是因为任何一个引用类型的对象都有一个对象头,对象头中是可以存 储当前获取锁的一个线程ID和进入次数,还可以存储同步块索引,同步块包含了所属线程对象,进入 次数,事件对象等。
- 事件对象可以让线程进入等待状态和唤醒线程,同步块可以按照需要创建并自动释放。
为什么加锁不能用string?
- 因为string是一个特殊的引用类型,具有不变性和字符串驻留。
- 整个程序中任何字符串都只有一个实例,因此只要在程序中的任何位置加锁具有相同内容的字符串, 就会锁定程序中该字符串的所有实例。
- 说的更直白点,我就是想锁这一块儿的内容,结果把所有跟该字符串内容相同的区域都锁了。
读写锁
是什么
- 读写锁适用于频繁读取,并且读取需要一定时间的场景,尤其是所谓的共享资源。共享资源的读取操 作通常是可以同时执行的,而普通的互斥锁,不管是读取还是修改都是无法同时执行的。如果说多个 线程为了读取操作而去获取一个互斥锁,那么同一时间只有一个线程可以执行读取操作,在频繁读取 的情况下,就会对吞吐量造成影响。
- 多线程写入同一个文件,会经常发生文件被占用而且无法访问的可能,如果不使用读写锁,而使用多 线程写入文件就会失败很多次,使用读写锁之后就可以确保每个线程都可以完成写入操作。
- 读写锁的本质上也是一个混合锁。
ReaderWriterLock
- 读写锁:ReaderWriterLockSlim,不同的ReaderWriterLockSlim对象中锁定同一个文件也会被视为不同 的锁进行管理,这种差异可能会再次导致文件的并发写入问题,所以ReaderWriterLockSlim应尽量定 义为只读的静态对象。
- ReaderWriterLockSlim类似于ReaderWriterLock但简化了递归规则以及升级和降级锁定状态的规则,可 避免多种潜在的死锁情况,性能明显优于ReaderWriterLock。
- ReaderWriterLock:用于同步资源访问,允许多线程同时进行读访问,或者单线程写访问,在资源不 经常发生更改的情况下,吞吐量比简单的一次只允许一个线程的锁更高。
- EnterWriteLock:进入写入状态,调用线程进入锁定状态之前一直处于阻塞状态。
- TryEnterWriteLock:进入写入状态,可指定阻塞的间隔时间,调用线程在间隔期间并未进入写入模式返 回false。
- ExitWriteLock:退出写入状态,应使用finally块执行ExitWriteLock方法,确保调用方退出写入模式。
信号量
是什么
- 信号量是一个具有特殊用途的线程同步对象。
- 信号量内部使用一个数值来记录可用的数量,每一个线程都可以通过增加这个数量和减少这个数量进 行同步。
- 在执行减少数量的时候如果减少的数量>现有数量,线程就会进入等待状态,一直到其它的线程去增加 这个数量,并且这个数量不少于减小的数量为止。
- 比如学校的自习室:互斥锁管理的是某个座位是否被占用,信号量管理的是整个自习室中未被占用的 座位数量,每当有人占用一个座位,这个自习室中的座位就会减少,当有人离开这个自习室座位就会 增加。如果信号量为0那就说明所有的座位都被占用了,那么新来的人就需要等待。
- 互斥锁是谁获取这个锁谁就负责释放这个锁。信号量的增加和减少的线程可以是不同的。
- 信号量最常见的场景就是实现生产消费模式,生产消费模式一般是一个队列,有一部分线程向队列里 面添加任务,另一部分线程从队列里面取出任务并处理。
Semaphore/SemaphoreSlim
SemaphoreSlim是阉割版的,不支持跨进程。
volatile
是什么
在多线程环境下,一个被volatile修饰的变量,其值被多线程读写时永远操作的是最新的值,在多线程环境下的操作保证了值的一致性。
原理
- 多线程同时访问一个变量,CLR为了效率,允许每个线程进行本地缓存,导致了变量的不一致性。
- volatile修饰的变量,不允许线程进行本地缓存,每个线程的读写都是直接操作在共享内存上,写操作 可以保证写到共享内存上,读操作也能保证从共享内存上读到最新的值,这就能保证变量始终具有一 致性。
作用
- volatile关键字有两个作用。
- 第一个可以保证在多线程环境下共享变量的可见性。
- 第二个是可以通过增加内存屏障,去防止多个指令之间的一个重排序。
- 可见性是指当一个线程对于共享变量的修改,其它线程可以立刻看到修改之后的一个值。
- 可见性的问题本质上是由几个方面来造成的。
- 首先是CPU层面的高速缓存,在CPU里面设计了三级缓存去解决CPU运算效率和内存IO效率的问题, 但是它也带来了缓存一致性的问题。
- 而在多线程并行执行的情况下,缓存一致性问题就会导致可见性问题。
- 所以对于volatile关键字修饰的共享变量,CLR会自动增加一个#Lock汇编指令。
- 而这个指令会去根据不同的CPU型号,去自动添加总线锁,或者缓存锁。
- 总线锁,锁定的是CPU的前端总线,从而去导致在同一个时刻,只能有一个线程和内存通信,这样就 能避免多线程并发造成的可见性问题。
- 缓存锁,是对总线锁的一个优化,因为总线锁导致CPU的使用效率大幅度下降。所以缓存锁,只针对 于CPU三级缓存中的目标数据去加锁,缓存锁是使用MESI缓存一致性协议来实现的。
- 指令重排序的问题,就是说指令在编写的数据和执行的顺序是不一致的,从而在多线程环境下导致可 见性的问题。
- 指令重排序本质上是一种性能优化的手段,它来自于几个方面。
- 首先是CPU层面,针对于MESI协议的更进一步的优化,去提升CPU的一个利用率,所以它引入了一 个叫StoreBuffer的一个机制,而这种优化机制,会导致CPU的乱序执行。
- 为了避免这样的问题,CPU提供了内存屏障指令,上层应用可以在合适的地方去插入内存屏障,去避 免CPU指令重排序的一个问题。
- 还有编译器层面的优化,编译器在编译的过程中,在不改变单线程语义和程序正确性的前提下,对指 令进行合理的重排序,从而去优化整体的一个性能。
- 所以,对于共享变量增加volatile关键字,编译器层面就不会去触发编译器的优化,同时在CLR里面, 它会去插入内存屏障指令,来避免重排序的问题。
后台线程/前台线程
- 后台线程,进程结束,线程结束。
- 前台线程,任务结束,线程结束。
Thread里面Sleep和Join的区别
Sleep是主线程自己阻塞自己,Join是子线程阻塞主线程。
Thred/TreadPool
- ThreadPool其实就是Tread的集合,在任务多的时候全局队列会存在竞争而消耗资源。
- Thread默认是前台线程。
- ThreadPool没有很好的API区域控制,如果线程执行无响应就只能等待结束,从而产生Task。
如何理解线程安全
- 在多个线程去访问某个方法或者对象的时候,不管通过任何方式的调用,或者线程如何去交替执行。
- 在程序中,不做任何同步干预的情况下,这个方法或者对象的执行,都能够按照预期的结果 来返回。
- 那么这个类,就是线程安全的。
- 线程安全问题的具体表现主要就是原子性。
- 原子性就是一个线程执行一系列程序指令操作的时候,它应该是不可中断的。
- 因为一旦出现了中断,站在多线程的角度来看,这一系列的程序指令,会出现前后执行结果不一致的 问题。这个和数据库里面的原子性是一样的。
多线程并发问题
- 线程锁:Lock不能用string,如果声明的两个string内容一样,会形成死锁。
- 可以用Lock加锁,保证线程安全。
- 可以用.Net框架提供的线程安全的集合来保证线程安全。
线程取消
CancellationTokenSource
线程通信
SynchronizationContext表示线程上下文,实现一个线程和另外一个线程的通讯。
- Send:当前线程上去调用委托实现,同步调用。子线程上直接调用UI线程执行,等UI线程执行完子线 程才执行。
- Post:在线程池上去调用委托实现,异步调用。子线程会从线程池中找出一个线程去调UI线程,子线程 不等的UI线程的完成而直接执行自己的逻辑。
异步操作
- 异步操作是执行某项操作后不等待结束,但是可以在操作结束后收到通知。
- 一个线程的栈空间,默认会分配8-10M的空间,栈空间是在内存里的,太多的线程就会让内存不足。
- 线程的创建都是需要消耗内存空间的,每创建一个栈空间都会产生内存的开销,当内存的使用超过了 物理内存的时候,那么一部分的数据就会放到磁盘上,随之而来的就是性能大幅度下降。
- 而且太多的线程也会让操作系统去调度线程所花费的时间变长,会导致性能下降。
- 解决这个问题的方法之一就是事件循环机制。
- 事件循环机制就是程序使用一个或多个线程专门用于捕获对象的检查状态,把执行的阻塞操作换成非 阻塞操作,然后再去注册事件能在处理完成后收到通知。
- JS是单线程的,JS中的异步就是使用事件循环机制来实现的,比如DOM事件,ajax,axios都是异步 事件。这种做法就避免了同时执行阻塞操作而需要多个线程执行的问题,但是这种事件循环机制的 代码难度非常高,所以提供了一些基于回调 的异步操作,在执行异步操作的时候在执行一个非阻塞操 作,然后注册事件,关联回调,能让接收事件之后,自动关联之前的回调。
- .NET中的异步编程模型在windows上就是基于IOCP来实现的,IOCP简单来说就是一组支持多个同时 发生的异步IO操作接口。在Linux上是基于Epoll模型来实现的,Epoll模型就是一个多路复用的IO接 口。
- 由于基于回调的这个异步编程模型的通用性不强,.NET在其基础之上又封装了一层,以任务也就是Task 为基础的接口,也就是任务并行库TPL,TPL最大的特点就是分离了执行异步操作与注册回调的处理, 这样一来就可以让任何异步操作都具有相同的方法注册回调,等待结束和处理错误,为之后的aysnc 和await打下了一个基础。
- Task类型就是表示执行了一个异步的方法。
- 如果使用Task类型作为返回结果去创建100个Task对象,而Task类型又是个引用类型,那么创建对 象的时候就会从托管堆中去分配内存,创建过多的引用类型对象又会频繁的触发GC,从而影响程序的 执行性能,那为了减少这种场景下的性能消耗,在.NET里面还提供了一个ValueTask类型。它实际上是 一个Task类型的包装类,是个值类型,创建它的时候是不需要从托管堆中分配内存的。如果异步操作 可以同步完成,这个时候就可以选择一个叫做ValueTask的类型,回调可以立刻被调用,并且没有任何 额外的开销。
- 异步的性能实际上比多线程要高,异步内部使用的是线程池,池里面的线程基本上都处于激活状态, 它不会反复的去造成多次的线程切换,线程切换消耗的性能主要是上下文的切换,上下文的切换主要 就是唤醒和等待,这两个切换是比较消耗资源的,但是在线程池里面的线程都是属于唤醒状态的, 所 以这个时候是没有什么性能消耗的。所以说现在能用异步就用异步,无脑异步。
Task
是什么
- Task类型就是表示执行了一个异步的方法。
- 如果使用Task类型作为返回结果去创建100个Task对象,而Task类型又是个引用类型,那么创建对 象的时候就会从托管堆中去分配内存,创建过多的引用类型对象又会频繁的触发GC,从而影响程序的 执行性能,那为了减少这种场景下的性能消耗,在.NET里面还提供了一个ValueTask类型。它实际上 是一个Task类型的包装类,是个值类型,创建它的时候是不需要从托管堆中分配内存的。如果异步操 作可以同步完成,这个时候就可以选择一个叫做ValueTask的类型,回调可以立刻被调用,并且没有任 何额外的开销。
任务控制
- Task.Wait():就是等待任务执行完成,Task状态变为Completed。
- Task.WaitAll():等待所有任务完成。
- Task.WaitAny():等待任何一个任务完成就继续向下执行。
- Task.ContinueWith():第一个Task完成后自动启动下一个Task,进行Task的延续。
Task.Run/Task.Factory.StartNew
- Task.Run就是简单的将工作交给线程池处理。
- Task.Run的参数TaskScheduler取的是默认对象Default。
- TaskScheduler可以用来控制任务的调度和运行。
- Task.Factory.StartNew可以做到更精细的控制。
- TaskCreationOptions可以用来控制任务的行为。
Thread/ThreadPool/Task/Parallel
- Tread的优点在于提供了丰富的多线程操作API,缺点在于线程的个数使用不加以限制,以及无法复用 线程,因此推出了TreadPool技术。
- TreadPool的优点在于解决了线程的个数限制以及线程的复用,缺点在于API较少而且线程的等待问题 解决起来较为麻烦,所以推出了Task。
- Task的优点在于丰富的API,以及多线程的方便管理,缺点在于线程的数量控制起来较为麻烦,因此推 出了Parallel技术。
- Parallel的优点在于丰富的API,多线程方便管理,线程的数量控制起来比较简单,但是主线程也会参 与计算,导致主线程的阻塞,但是这个问题可以用Task.Run来包一层解决。
Parallel
多线程进行并行迭代
TreadPool
不断的开启线程和销毁,存在很大开销,池化思想,用完放回线程池,不销毁,享元模式。
线程池还可以控制线程总数量。ManualResetEvent
TreadPool线程式开关:ManualResetEvent
async/await
async/await是什么
- 用aync和await这两个组合可以简化异步方法的编写。
- 如果主线程遇到了await关键字,主线程就会回来执行自己的逻辑,await等待的任务是新开启线程的 执行,await后面的代码等到新开启线程的任务执行完毕以后再执行。
- 所以用async和await这组关键字,它是不阻塞主线程的。
- 异步的性能实际上比多线程要高,异步内部使用的是线程池,池里面的线程基本上都处于激活状态, 它不会反复的去造成多次的线程切换,线程切换消耗的性能主要是上下文的切换,上下文的切换主要 就是唤醒和等待,这两个切换是比较消耗资源的,但是在线程池里面的线程都是属于唤醒状态的, 所 以这个时候是没有什么性能消耗的。所以说现在能用异步就用异步,无脑异步。
async/await会创建新的线程吗
不会。aync/await关键字本身不会创建新的线程,await的方法内部一般会创建新的线程。
MVC/WebAPI中的Action中使用aync/await会提高请求的响应速度吗?
- 不会。
- Web应用本身就是多线程模式,服务器会为每个HTTP的请求分配工作线程。
- 使用同步方法时,线程会被耗时操作一直占有,直到耗时操作完成。
- 使用异步方法时,程序走到await关键字时会立即return,释放当前的线程,余下的代码会进入到一个 回调中,耗时操作完成时才会触发回调去执行。
- 像磁盘的IO或者网络的IO,如果有耗时操作时才考虑使用异步操作。
- async和await这组关键字,它也不能提升HTTP请求的响应速度,但是可以提升HTTP请求的响应能力 (吞吐量)。
async/await原理
- 跟同步方法相比,CLR在执行异步方法的时候,它有几个不同的特点。
- 异步方法它并不是一次性就执行完成的,而是分了多次执行才完成的。
- 而且异步方法也不是由同一个线程来完成执行的,而是从线程池里面每次动态分配一个线程来执行。
- 所以结合异步方法的这些执行特点,C#编译器会将异步方法转换成一个状态机的结构。这种结构它能 进行挂起和恢复。
- 而且它的执行方式也是一种工作流的方式。
async/awati执行步骤
- CLR会创建了一个状态机,这个状态机的操作数默认值是-1。
- 然后开始执行状态机。
- 状态机通过这个操作数来选择执行的路径。
- 状态机调用GetAwaiter方法来获取一个等待的对象awaiter,它的类型是TaskAwaiter。
- 状态机在获取awaiter对象之后,会查询awaiter对象的IsCompleted属性。
- 如果IsCompleted属性为True,那么操作就会以同步的方式来执行。
- 如果IsCompleted属性为False,那么操作就会以异步的方式来执行,然后状态机会调用awaiter对象的 OnCompleted方法并给它传递一个委托。
- 这个时候状态机就会允许当前线程返回到原来的地方去执行后面的代码。
- 然后在将来的某个时候,awaiter对象会在完成时调用当时给OnCompleted方法传递的委托然后执行 MoveNext方法,这个时候就可以根据状态机中的某个字段来知道如何到达代码中正确的位置,使方法 能够从当初离开的位置继续执行。
- 最后会调用awaiter对象的GetResult方法获取最终的结果。
ConfigureAwait(false)
- 使用await时,在执行完异步任务后,系统会尝试返回之前的线程继续执行。
- 使用ConfigureAwait(false)时,系统会在执行完异步任务后不会尝试返回之前的线程(不捕获线程的上下 文),而是在线程池里面找一个空闲的线程执行接下来的代码。
- 使用ConfigureAwait(false)在非UI线程执行await任务时非常有用,因为调用异步任务的线程此时可能 正在忙着做其它事情,没有空接着执行后面的代码。
Invoke/BeginInvoke
- Invoke调用时,会阻止当前主线程的运行,等待Invoke方法返回时才继续执行后面的代码,类似于同 步执行。
- BeginInvoke调用时,当前线程会启动线程池中的某个线程来执行方法,BeginInvoke不会阻止当前主线 程的运行,而是等当前主线程做完事情之后再执行BeginInvoke中的代码,类似于异步执行。想获取 BeginInvoke执行完毕后的结果,可以调用EndInvoe方法来获取。
CPU飙升的原因
- CPU是整个计算机的核心计算资源。
- 对于一个应用程序来说,CPU的最小执行单元是线程。
- 导致CPU飙升的原因,可能是CPU的上下文切换过多。
- 对CPU来说,同一个时刻下,每个CPU核心只能运行一个线程。
- 如果有多个线程要去被执行,CPU只能通过上下文切换的方式来调度不同的线程。
- 上下文切换需要保存运行中线程的执行状态,还得让处于等待中的线程恢复执行。
- 这两个过程都需要CPU执行内核的相关指令,去实现状态的保存和恢复。
- 如果有较多的上下文切换,就会占据大量的CPU资源。
- 从而使得CPU无法执行用户进程中的真正指令,导致响应速度下降。
- 在C#中,文件IO,网络IO,锁等待,这些都会造成线程的阻塞,导致CPU的上下文切换。
- 还有一种情况就是CPU的资源被过度消耗了,也就是在程序中创建了大量的线程或者有线程一直占据 CPU的资源,无法被释放,比如死循环。
- CPU的利用率过高,就会导致应用程序中的线程,无法获得CPU的调度,从而影响程序的执行效率。
- 所以可以在任务管理器里面找到CPU利用率较高的进程,然后找到进程中CPU消耗过高的线程。
- 但是这里有两种情况。
- 第一种情况就是CPU利用率过高的线程一直是同一个,也就是线程的ID没有变化,说明在程序中存在 着长期占用CPU没有释放的一个情况,那么这种情况需要把线程的内存地址和线程的ID都记录到日志 文件里面,然后去找出现问题的代码。
- 第二种是CPU利用率过高的线程ID,一直在不断的变化,那么说明线程创建过多,需要去挑选几个线 程的ID,然后去排查。
- 反正就是不好查,有可能定位的结果是程序正常,只是在CPU飙升的那一刻,用户访问量非常大导致 系统的资源不够。
- 像这种情况,就要去增加系统的资源,换性能更高的CPU。
多线程排错
- 多线程启动无序,执行无序,结果无序,所以哪个线程报错了必须给每个线程包上try catch进行异常 捕获,然后加日志,把线程的内存地址打印出来。
- 如果在visual studio的调试中报错了,可以直接从诊断工具中看是哪个线程报错了,报了什么错,从诊 断工具中点击该异常,visual studio可以激活历史调试。
吞吐量/并发量
- 吞吐量是指单位时间内的数据总量。
- 并发量是指单位时间内的请求数量。
- 比如一条双行道的公路,只能同时过2辆车,1个小时同行了100辆车。这里的2就是并发量,这里 的100就是吞吐量。
并行/并发
- 并行和并发最早描述的是在C#并发编程里面的一个概念,强调的是CPU处理任务的一个能力。
- 并发就是同一个时刻CPU能够处理的任务数量。
- 并行就是在同一个时刻,允许多个任务同时执行。
- 在多核CPU的架构里面,同时执行任务的数量是由CPU的核心数量决定的。
- 比如说四核四线程的CPU里面,只能同时执行四个线程。
- 并发描述的是程序处理能力的视角。
- 而并行描述的是CPU处理任务方式的一个视角。
- 它们两个相辅相成,CPU并行执行任务的能力又能够提升程序的并发处理性能。
- 所以多核CPU的性能要比单核CPU要好。
- 如果是单核CPU也可以通过时间片切换的方式去提升并发的能力。