.Net 多线程开发优化实践
互联网产品中微服务、高并发已经成为最基本的要求。所谓高并发就是在同一时刻处理多个服务请求。为了提高高并发场景下的系统稳定性,负载均衡、消息队列等框架和技术应运而生,有效的缓解了高并发对系统整体压力。无论是这些框架和技术,还是日常并发编程都离不开一个基础:多线程。以下我们就聊一聊多线程这种最基础的处理并发的方式。
无论是云服务还是传统应用,不可或缺的组成部分就是线程。为了实现系统的并行处理业务,Microsoft自Windows NT引入了多线程。经过多年的积累与发展,无论是线程的稳定性还是使用的便捷性都有巨大的提升。随着.NET的普及,线程的创建和使用也更加方便。Windows系统中的线程也动辄数千个线程在运行。
通过多线程的应用,能够让操作系统更加顺畅,让系统可以并行处理业务。那是不是我们就可以无限度的使用线程了呢?天下没有免费的午餐,线程的使用也是有代价的。特别是高并发的情况下。我们对线程的使用需要更加谨慎。线程是如何运行的,它们又有哪些资源上的损耗。只有了解了线程内部运行机制,我们才能更好的利用线程、发挥其作用。
一、线程的内存开销
单个CPU在同一时刻只能执行一个指令,为了实现线程“并行”执行的要求。Windows通过时间片控制线程间调度执行。为了线程间调度,操作系统为每个线程分配一定的内存,用于保存线程的初始化信息、上下文信息、入参信息等。每个线程都包含以下几个部分:
1、线程内核对象
2、线程环境块
3、用户模式栈
4、内核模式栈
以x64操作系统为例(x86相差无几),其中线程内核对象占用1k内存、线程环境块占用4kB内存、用户模式栈占用1M内存、内核模式栈占用24k内存。每个线程都需要占用1M+的内存。
如上图所示,大量开启线程,必然造成内存的“泄露”。如果进程运行在x86平台,因其内存最大寻址为2G,当开启线程为1500左右,就会出现经典的OutOfMemoryException。虽然x64基本上没有了此约束,但是也并不是可以无限度的滥用线程,毕竟内存资源是有限的。
特来电互联互通系统中,进行充电桩状态推送是并发较大的一个场景。当充电桩设备状态发生变化,例如由插枪到启动充电、启动充电到停止充电等等。一个充电桩状态发生变化后,需要及时推送给所有此充电桩关联的互联互通厂商。随着充电桩和厂商数量的增多,推送的数据量也成倍数增加。以下是数据推送的TPM图。
从图中可见,推送TPM基本在2000左右,高峰时刻为6000左右。推送服务受接收方影响较大,不可控因素更多。如果每次推送都启动一个线程,然后在线程中进行数据推送,系统的线程数就完全不可控。同时建立2000个线程,单单这一个功能线程消耗的内存资源就有2G以上,当内存资源被耗尽的时候就会出现系统宕机等问题。如果不启动线程,我们就无法很好的保证一次终端状态变化,相关的商户都能第一时间接收到推送数据。权衡资源与需求,要求我们合理利用资源,保证互联互通商户及时收到推送信息的同时,保证系统的线程数的稳定。
分布式、微服务从宏观框架上帮我们解决了这个问题;合理使用线程,在微观编程上保证了系统的稳定。
首先将推送信息在MQ消息队列缓冲,消息队列将推送信息分发至分布的各个节点进行处理,通过消息队列,将请求进行了缓冲和分发,有效的缓解了各个节点压力。
为了有效的进行消息消费,保证商户及时接收到推送信息,各个节点程序内部,我们采用了二次缓存,将需要推送的信息缓存至本地内存中,然后每个互联互通商户启动一个线程进行推送。这样既保证了各个商户都能及时收到推送的信息,也极大的控制了线程数量,避免的线程数的激增。
通过框架和线程的配合,有效的保证了推送消息的及时送达,并保证了系统线程的稳定。
二、线程的执行性能
线程在创建以及线程间调用时需要执行寄存器、内存等操作,这些操作会造成一定的性能损耗。损耗主要包括以下两个方面:
1、线程创建及销毁时的通知
2、线程上下文切换
Windows在进程中创建和销毁线程时,会调用进程中加载的所有非托管DLL的DllMain方法,以便通知相关DLL进行资源初始化或销毁。虽然托管的DLL中不存在DllMain方法,因此不会存在此问题。但Windows每个进程动辄就是加载几百个Dll,其中不乏大量的非托管Dll,因此这部分损耗也是不小的。
线程对执行性能影响最大的是上下文切换。对于一个CPU来说,在某一个时刻只会有一个线程在执行,而我们感觉中多线程同时执行(不讨论多CPU),功劳就是Windows的线程调度。Windows会给每个线程分配一个时间片,用于线程运行。此时间片到期后,Windows将执行另一线程,此时Windows就会进行上下文切换。Windows进行上下文切换时,将执行如下操作:
1、将当前线程的CPU寄存器的值保存在线程的内核对象的上下文结构中。
2、从线程集合中获取一个可执行的线程。
3、将所获取的线程的上下文结构加载到CPU寄存器中。
4、CPU执行所选的线程。
Windows大约每30ms执行一个上下文切换,但当Windows在一个时间片结束后继续执行同一个线程则不会出现上下文切换。为了减少上下文切换带来的损耗,我们要合理的使用线程,只有需要的时候才创建线程,避免上下文的切换。
三、线程的调度
Windows进行线程调用是按照其优先级进行的。Windows中的线程优先级为0(最低)~31(最高)。如存在可以执行的优先级为31的线程时,就不会执行0~30的线程。
为了让开发人员更好的理解优先级,而不直接设置线程优先级。Microsoft对进程和线程进行优先级类描述。其中进程为了6类:Idle、Below Normal、Normal、Above Normal、High、Realtime。线程级别为7类:Idle、Lowest、Below Normal、Normal、Above Normal、Highest、Time-Critical。线程的优先级是由进程优先级类和线程优先级类一起确定的,根据进程优先级类和线程优先级类构成线程的基本优先级。对应关系为:
|
进程优先级 |
|||||
线程优先级 |
Idle |
Below Normal |
Normal |
Above Normal |
High |
Realtime |
Idle |
1 |
1 |
1 |
1 |
1 |
16 |
Lowest |
2 |
4 |
6 |
8 |
11 |
22 |
Below Normal |
3 |
5 |
7 |
9 |
12 |
23 |
Normal |
4 |
6 |
8 |
10 |
13 |
24 |
Above Normal |
5 |
7 |
9 |
11 |
14 |
25 |
Highest |
6 |
8 |
10 |
12 |
15 |
26 |
Time-Critical |
15 |
15 |
15 |
15 |
15 |
31 |
以上表中有部分优先级并没有体现,这是因为Windows将其预留,分配给零页线程和驱动线程等。
通过进程和线程优先级类确定的线程优先级是不是在其运行过程中就不会改变了呢?不是的。线程初始化时,线程优先级等于基础优先级。但为了保证线程的可响应性,Windows会动态对线程优先级进行调整。
如图所示,任务管理器中一个线程基本优先级为8,即进程优先级类和线程优先级类为Normal,但其运行过程中的优先级为10,Windows已经进行了优先级调整。(对于16~31的线程,系统不会提升其优先级)
由此可见如果我们需要快速响应某个操作我们可以提高其优先级,但通常情况下我们不应该提高其优先级,以免影响其他线程的执行。只有需要及时响应并执行时间很短时才考虑调高其线程优先级,集中资源办急事。
一般桌面软件(例如wps线程数为25个左右),线程数并不高,并且不会有大的波动,而在高并发系统的每个节点程序中也至少有200以上个线程在运行。如果我们不能很好的使用这些线程,或者无节制的创建线程。线程数将呈倍数增长,系统复杂度和内存等资源都将出现不可控,影响系统运行,甚至出现宕机。因此越是高并发越要合理使用线程。
随着CPU的多核、超线程等的使用,从硬件底层对线程的损耗已经进行了一定的弥补。我们的CPU利用率,基本都是在10%左右,远远没有发挥其真正价值。了解线程,就是为了更恰当、合理的使用线程。当我们需要最大限度的利用线程就需要对这些基础知识深入了解。基础知识是航标,虽然不需要一直盯着它,但它却指示着我们的方向。