如何理解高性能服务器的高性能、高并发?之一_风闻
蓝海大脑GPU服务器-水冷服务器、大数据一体机、图数据一体机01-12 18:32

线程 | 同步 | 异步 | 异构
协程 | 进程 | 同构 | 线程池
当前,随着“东数西算”政策的落地,算力时代正在全面开启。随着机器学习、深度学习的快速发展,人们对高性能服务器这一概念不再陌生。伴随着数据分析、数据挖掘数目的不断增大,传统的风冷散热方式已经不足以满足散热需要,这就需要新兴的液冷散热技术以此满足节能减排、静音高效的需求。
作为国内品牌服务器厂商,蓝海大脑液冷GPU服务器拥有大规模并行处理能力和无与伦比的灵活性。它主要用于为计算密集型应用程序提供足够的处理能力。GPU的优势在于可以由CPU运行应用程序代码,同时图形处理单元(GPU)可以处理大规模并行架构的计算密集型任务。GPU服务器是遥感测绘、医药研发、生命科学和高性能计算的理想选择。
本文将为大家全面介绍高性能GPU服务器所涉及技术以及如何搭建。

线程与线程池
下面将从CPU开始路来到常用的线程池,从底层到上层、从硬件到软件。一、CPU
对此大家可能会有疑问,讲多线程为什么要从CPU开始?实际上CPU并没有线程、进程之类的概念。CPU所作的就是从内存中取出指令——执行指令,然后回到1。
1、CPU从哪里取出指令
就是我们熟知的程序计数器,在这里大家不要把寄存器想的太神秘,可以简单的将寄存器理解为内存,只不过存取速度更快而已。
2、PC寄存器中存放的是什么?
指令(CPU将要执行的下一条指令)在内存中的地址

3、谁来改变PC寄存器中的指令地址?
由于大部分情况下CPU都是一条接一条顺序执行,所以之前PC寄存器中的地址默认是自动加1。但当遇到if、else时,这种顺序执行就被打破了,为了正确的跳转到需要执行的指令,CPU在执行这类指令时会根据计算结果来动态改变PC寄存器中的值。**4、PC中的初始值是怎么被设置的?**CPU执行的指令来自内存,内存中的指令来自于磁盘中保存的可执行程序加载,磁盘中可执行程序是由编译器生成的,编译器从定义的函数生成的机器指令。

二、从CPU到操作系统
从上面我们明白了CPU的工作原理,如果想让CPU执行某个函数,只需把函数对应的第一条机器执行装入PC寄存器就可以了,这样即使没有操作系统也可以让CPU执行程序,虽然可行但这是一个非常繁琐的过程(1、在内存中找到一块大小合适的区域装入程序;2、找到函数入口,设置好PC寄存器让CPU开始执行程序)。机器指令由于需加载到内存中执行所以需要记录下内存的起始地址和长度;同时要找到函数的入口地址并写到PC寄存器中。数据结构大致如下:1234567struct *** { void* start_addr; int len; void* start_point; …};三、从单核到多核,如何充分利用多核
如果一个程序需要充分利用多核就会遇到以下问题:
1、进程是需要占用内存空间的(从上一节到这一节),如果多个进程基于同一个可执行程序,那么这些进程其内存区域中的内容几乎完全相同,显然会造成内存浪费;
2、当计算机处理的任务比较复杂时就会涉及到进程间通信,但是由于各个进程处于不同的内存地址空间,而进程间通信需要借助操作系统,在增大编程难度的同时也增加了系统开销。
四、从进程到线程
进程到线程即内存中的一段区域,该区域保存了CPU执行的机器指令以及函数运行时的堆栈信息。要想让进程运行,就把main函数的第一条机器指令地址写入PC寄存器。
进程的缺点在于只有一个入口函数(main函数),进程中的机器指令只能被一个CPU执行,那么有没有办法让多个CPU来执行同一个进程中的机器指令呢?可以将main函数的第一条指令地址写入PC寄存器。main函数和其它函数没什么区别,其特殊之处无非在于是CPU执行的第一个函数。当把PC寄存器指向非main函数时,线程就诞生了。
至此一个进程内可以有多个入口函数,也就是说属于同一个进程中的机器指令可以被多个CPU同时执行。
多个CPU可以在同一个屋檐下(进程占用的内存区域)同时执行属于该进程的多个入口函数。操作系统为每个进程维护一堆信息,用来记录进程所处的内存空间等,这堆信息记为数据集A。同样的,操作系统也为线程维护一堆信息,用来记录线程的入口函数或者栈信息等,这堆数据记为数据集B。显然数据集B要比数据A的量要少,由于线程是运行在所处进程的地址空间在程序启动时已经创建完毕,同时线程是程序在运行期间创建的(进程启动后),所以当线程开始运行的时候这块地址空间就已经存在了,线程可以直接使用。值得一提的是,有了线程这个概念后,只需要进程开启后创建多个线程就可以让所有CPU都忙起来,这就是所谓高性能、高并发的根本所在。
**另外值得注意的一点是:**由于各个线程共享进程的内存地址空间,所以线程之间的通信无需借助操作系统,这给工作人员带来了便利同时也有不足之处。多线程遇到的多数问题都出自于线程间通信太方便以至于非常容易出错。出错的根源在于CPU执行指令时没有线程的概念,多线程编程面临的互斥与同步问题需要解决。
**最后需要注意的是:**虽然前面关于线程讲解使用的图中用了多个CPU,但并不一定要有多核才能使用多线程,在单核的情况下一样可以创建出多个线程,主要是由于线程是操作系统层面的实现,和有多少个核心是没有关系的,CPU在执行机器指令时也意识不到执行的机器指令属于哪个线程。即使在只有一个CPU的情况下,操作系统也可以通过线程调度让各个线程“同时”向前推进,即将CPU的时间片在各个线程之间来回分配,这样多个线程看起来就是“同时”运行了,但实际上任意时刻还是只有一个线程在运行。
五、线程与内存前面介绍了线程和CPU的关系,也就是把CPU的PC寄存器指向线程的入口函数,这样线程就可以运行起来了。无论使用任何编程语言,创建一个线程大体相同:12345// 设置线程入口函数DoSomethingthread = CreateThread(DoSomething); // 让线程运行起来thread.Run();函数在被执行的时产生的数据包括:函数参数、局部变量、返回地址等信息。这些信息保存在栈中,线程这个概念还没有出现时进程中只有一个执行流,因此只有一个栈,这个栈的栈底就是进程的入口函数,也就是main函数。假设main函数调用了funA,funcA又调用了funcB,如图所示:

有了线程以后一个进程中就存在多个执行入口,即同时存在多个执行流,只有一个执行流的进程需要一个栈来保存运行时信息,显然有多个执行流时就需要有多个栈来保存各个执行流的信息,也就是说操作系统要为每个线程在进程的地址空间中分配一个栈,即每个线程都有独属于自己的栈,能意识到这一点是极其关键的。同时创建线程是要消耗进程内存空间的。
六、线程的使用从生命周期的角度讲,线程要处理的任务有两类:长任务和短任务。**1、长任务(long-lived tasks)**顾名思义,就是任务存活的时间长。以常用的word为例,在word中编辑的文字需要保存在磁盘上,往磁盘上写数据就是一个任务,这时一个比较好的方法就是专门创建一个写磁盘的线程,该线程的生命周期和word进程是一样的,只要打开word就要创建出该线程,当用户关闭word时该线程才会被销毁,这就是长任务。长任务非常适合创建专用的线程来处理某些特定任务。**2、短任务(short-lived tasks)**即任务的处理时间短,如一次网络请求、一次数据库查询等。这种任务可以在短时间内快速处理完成。因此短任务多见于各种Server,像web server、database server、file server、mail server等。该场景有任务处理所需时间短和任务数量巨大的两个特点。
这种工作方法可对长任务来说很好,但是对于大量的短任务来说虽然实现简单但却有其缺点:1)线程是操作系统中的概念,因此创建线程需要借助操作系统来完成,操作系统创建和销毁线程是需要消耗时间的;
2)每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源。
这就好比一个工厂老板手里有很多订单,每来一批订单就要招一批工人,生产的产品非常简单,工人们很快就能处理完,处理完这批订单后就把这些工人辞掉,当有新的订单时再招一遍工人,干活儿5分钟招人10小时,如果你不是励志要让企业倒闭的话大概是不会这么做到的。因此一个更好的策略就是招一批人后就地养着,有订单时处理订单,没有订单时大家可以待着。这就是线程池的由来。
七、从多线程到线程池
线程池的无非就是创建一批线程之后就不再释放,有任务就提交给线程处理,因此无需频繁的创建、销毁线程,同时由于线程池中的线程个数通常是固定的,也不会消耗过多的内存。
**八、线程池是如何工作的?**一般来说提交给线程池的任务包含需要被处理的数据和处理数据的函数两部分。
伪码描述一下:1234struct task { void* data; // 任务所携带的数据 handler handle; // 处理数据的方法}线程池中的线程会阻塞在队列上,当工作人员向队列中写入数据后,线程池中的某个线程会被唤醒,该线程从队列中取出上述结构体(或者对象),以结构体(或者对象)中的数据为参数并调用处理函数。
伪码如下:1234while(true) { struct task = GetFromQueue(); // 从队列中取出数据 task->handle(task->data); // 处理数据}八、线程池中线程的数量众所周知线程池的线程过少就不能充分利用CPU,线程创建的过多反而会造成系统性能下降,内存占用过多,线程切换造成的消耗等等。因此线程的数量既不能太多也不能太少,到底该是多少呢?从处理任务所需要的资源角度看有CPU密集型和I/O密集型两种类型。1、CPU密集型所谓CPU密集型是指说理任务不需要依赖外部I/O,比如科学计算、矩阵运算等。在这种情况下只要线程的数量和核数基本相同就可以充分利用CPU资源。
2、I/O密集型这一类任务可能计算部分所占用时间不多,大部分时间都用在磁盘I/O、网络I/O等方面。
工作人员需要利用性能测试工具评估出用在I/O等待上的时间,这里记为WT(wait time),以及CPU计算所需要的时间,这里记为CT(computing time),那么对于一个N核的系统,合适的线程数大概是 N * (1 + WT/CT) ,假设I/O等待时间和计算时间相同,那么大概需要2N个线程才能充分利用CPU资源,注意这只是一个理论值,具体设置多少需要根据真实的业务场景进行测试。当然充分利用CPU不是唯一需要考虑的点,随着线程数量的增多,内存占用、系统调度、打开的文件数量、打开的socker数量以及打开的数据库链接等等是都需要考虑的。所以没有万能公式,要具体情况具体分析。
九、使用线程前需要考虑的因素1、充分理解任务是长任务还是短任务、是CPU密集型还是I/O密集型,如果两种都有,那么一种可能更好的办法是把这两类任务放到不同的线程池。
2、如果线程池中的任务有I/O操作,那么务必对此任务设置超时,否则处理该任务的线程可能会一直阻塞下去;
4、线程池中的任务不要同步等待其它任务的结果。
I/O与零拷贝技术
一、什么是I/O?
I/O就是简单的数据Copy,如果数据从外部设备copy到内存中就是Input。如果数据是内存copy到外部设备则是Output。内存与外部设备之间不嫌麻烦的来回copy数据就是Input and Output,简称I/O(Input/Output)。

二、I/O与CPU
简单来说:CPU执行机器指令的速度是纳秒级别的,而通常的I/O比如磁盘操作,一次磁盘seek大概在毫秒级别,因此如果我们把CPU的速度比作战斗机的话,那么I/O操作的速度就是肯德鸡。
也就是说当程序跑起来时(CPU执行机器指令),其速度是要远远快于I/O速度。那么接下来的问题就是二者速度相差这么大,该如何设计、更加合理的高效利用系统资源呢?既然有速度差异,进程在执行完I/O操作前不能继续向前推进,那就只有等待(wait)。三、执行I/O时底层都发生了什么在支持线程的操作系统中,实际上被调度的是线程而不是进程,为了更加清晰的理解I/O过程,暂时假设操作系统只有进程这样的概念,先不去考虑线程。如下图所示,现在内存中有两个进程,进程A和进程B,当前进程A正在运行。如下图所示:
进程A中有一段读取文件的代码,不管在什么语言中通常定义一个用来装数据的buff,然后调用read之类的函数。1read(buff);注意:与CPU执行指令的速度相比,I/O操作操作是非常慢的,因此操作系统是不可能把宝贵的CPU计算资源浪费在无谓的等待上的。由于外部设备执行I/O操作是相当慢的,所以在I/O操作完成之前进程是无法继续向前推进的,这就是所谓的阻塞,即block。
只需记录下当前进程的运行状态并把CPU的PC寄存器指向其它进程的指令就操作系统检测到进程向I/O设备发起请求后就暂停进程的运行。进程有暂停就会有继续执行,因此操作系统必须保存被暂停的进程以备后续继续执行,显然我们可以用队列来保存被暂停执行的进程。
如上图所示,操作系统已经向磁盘发送I/O请求,因此磁盘driver开始将磁盘中的数据copy到进程A的buff中。虽然这时进程A已经被暂停执行了,但这并不妨碍磁盘向内存中copy数据。过程如下图所示:

操作系统中除了有阻塞队列之外也有就绪队列,所谓就绪队列是指队列里的进程准备就绪可以被CPU执行了。在即使只有1个核的机器上也可以创建出成千上万个进程,CPU不可能同时执行这么多的进程,因此必然存在这样的进程,即使其一切准备就绪也不能被分配到计算资源,这样的进程就被放到了就绪队列。
由于就绪队列中还有嗷嗷待哺的进程B,所以当进程A被暂停执行后CPU是不可以闲下来的。这时操作系统开始在就绪队列中找下一个可以执行的进程,也就是这里的进程B。此时操作系统将进程B从就绪队列中取出,找出进程B被暂停时执行到的机器指令的位置,然后将CPU的PC寄存器指向该位置,这样进程B就开始运行啦。

如上图所示,进程B在被CPU执行,磁盘在向进程A的内存空间中copy数据,数据copy和指令执行在同时进行,在操作系统的调度下,CPU、磁盘都得到了充分的利用。此后磁盘将全部数据都copy到了进程A的内存中,操作系统接收到磁盘中断后发现数据copy完毕,进程A重新获得继续运行的资格,操作系统把进程A从阻塞队列放到了就绪队列当中。

此后进程B继续执行,进程A继续等待,进程B执行了一会儿后操作系统认为进程B执行的时间够长了,因此把进程B放到就绪队列,把进程A取出并继续执行。操作系统把进程B放到的是就绪队列,因此进程B被暂停运行仅仅是因为时间片到了而不是因为发起I/O请求被阻塞。
**四、零拷贝(Zero-copy)**值得注意的一点是:上面的讲解中直接把磁盘数据copy到了进程空间中,但实际上一般情况下I/O数据是要首先copy到操作系统内部,然后操作系统再copy到进程空间中。性能要求很高的场景其实也是可以绕过操作系统直接进行数据copy,这种绕过操作系统直接进行数据copy的技术被称为零拷贝(Zero-copy)。I/O多路复用
本文我们详细讲解什么是I/O多路复用以及使用方法,这其中以epoll为代表的I/O多路复用(基于事件驱动)技术使用非常广泛,实际上你会发现但凡涉及到高并发、高性能的场景基本上都能见到事件驱动的编程方法。
**一、什么是文件?**在Linux世界中文件是一个很简单的概念,只需要将其理解为一个N byte的序列就可以了:b1, b2, b3, b4, ……. bN
实际上所有的I/O设备都被抽象了,一切皆文件(Everything is File),磁盘、网络数据、终端,甚至进程间通信工具管道pipe等都被当做文件对待。
常用的I/O操作接口一般有以下几类:1、打开文件,open;
2、改变读写位置,seek;
3、文件读写,read、write;
4、关闭文件,close。
**二、什么是文件描述符?**在上文中我们讲到:要想进行I/O读操作,像磁盘数据,需要指定一个buff用来装入数据。在Linux世界要想使用文件,需要借助一个号码,根据“弄不懂原则”,这个号码就被称为了文件描述符(file descriptors),在Linux世界中鼎鼎大名,其道理和上面那个排队号码一样。文件描述仅仅就是一个数字而已,但是通过这个数字我们可以操作一个打开的文件。
有了文件描述符,进程可以对文件一无所知,比如文件在磁盘的什么位置、加载到内存中又是怎样管理的等等,这些信息统统交由操作系统打理,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。三、文件描述符太多了怎么办?从上文中我们知道,所有I/O操作都可以通过文件样的概念来进行,这当然包括网络通信。如果你有一个IM服务器,当三次握手建议长连接成功以后,我们会调用accept来获取一个链接,调用该函数我们同样会得到一个文件描述符,通过这个文件描述符就可以处理客户端发送的聊天消息并且把消息转发给接收者。也就是说,通过这个描述符就可以和客户端进行通信了:// 通过accept获取客户端的文件描述符int conn_fd = accept(…);
**Server端的处理逻辑通常是接收客户端消息数据,然后执行转发(给接收者)逻辑:**if(read(conn_fd, msg_buff) > 0) { do_transfer(msg_buff);}
既然主题是高并发,那么Server端就不可能只和一个客户端通信,而是可能会同时和成千上万个客户端进行通信。这时需要处理不再是一个描述符这么简单,而是有可能要处理成千上万个描述符。为了不让问题一上来就过于复杂先简单化,假设只同时处理两个客户端的请求。**有的同学可能会说,这还不简单,这样写不就行了:**if(read(socket_fd1, buff) > 0) { // 处理第一个 do_transfer();}if(read(socket_fd2, buff) > 0) { // 处理第二个 do_transfer();
如果此时没有数据可读那么进程会被阻塞而暂停运行。这时我们就无法处理第二个请求了,即使第二个请求的数据已经就位,这也就意味着处理某一个客户端时由于进程被阻塞导致剩下的所有其它客户端必须等待,在同时处理几万客户端的server上。这显然是不能容忍的。聪明的你一定会想到使用多线程:为每个客户端请求开启一个线程,这样一个客户端被阻塞就不会影响到处理其它客户端的线程了。注意:既然是高并发,那么我们要为成千上万个请求开启成千上万个线程吗,大量创建销毁线程会严重影响系统性能。那么这个问题该怎么解决呢?这里的关键点在于:我们事先并不知道一个文件描述对应的I/O设备是否是可读的、是否是可写的,在外设的不可读或不可写的状态下进行I/O只会导致进程阻塞被暂停运行。**三、I/O多路复用(I/O multiplexing)**multiplexing一词多用于通信领域,为了充分利用通信线路,希望在一个信道中传输多路信号,要想在一个信道中传输多路信号就需要把这多路信号结合为一路,将多路信号组合成一个信号的设备被称为Multiplexer(多路复用器),显然接收方接收到这一路组合后的信号后要恢复原先的多路信号,这个设备被称为Demultiplexer(多路分用器)。如下图所示:
所谓I/O多路复用指的是这样一个过程:1、拿到一堆文件描述符(不管是网络相关的、还是磁盘文件相关等等,任何文件描述符都可以);
2、通过调用某个函数告诉内核:“这个函数你先不要返回,你替我监视着这些描述符,当这堆文件描述符中有可以进行I/O读写操作的时候你再返回”;
3、当调用的这个函数返回后就能知道哪些文件描述符可以进行I/O操作了。
三、I/O多路复用三剑客由于调用这些I/O多路复用函数时如果任何一个需要监视的文件描述符都不可读或者可写那么进程会被阻塞暂停执行,直到有文件描述符可读或者可写才继续运行。所以Linux上的select、poll、epoll都是阻塞式I/O,也就是同步I/O。1、select:初出茅庐在select I/O多路复用机制下,需要把想监控的文件描述集合通过函数参数的形式告诉select,然后select将这些文件描述符集合拷贝到内核中。为了减少这种数据拷贝带来的性能损耗,Linux内核对集合的大小做了限制,并规定用户监控的文件描述集合不能超过1024个,同时当select返回后,仅仅能知道有些文件描述符可以读写了。select的特点1、能照看的文件描述符数量有限,不能超过1024个;
2、用户给文件描述符需要拷贝的内核中;
3、只能告诉有文件描述符满足要求但不知道是哪个。
2、poll:小有所成poll和select是非常相似,相对于select的优化仅仅在于解决文件描述符不能超过1024个的限制,select和poll都会随着监控的文件描述数量增加而性能下降,因此不适合高并发场景。3、epoll:独步天下在select面临的三个问题中,文件描述数量限制已经在poll中解决了,剩下的两个问题呢?针对拷贝问题epoll使用的策略是各个击破与共享内存。文件描述符集合的变化频率比较低,select和poll频繁的拷贝整个集合,epoll通过引入epoll_ctl很体贴的做到了只操作那些有变化的文件描述符。同时epoll和内核还成为了好朋友,共享了同一块内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这样就减少了内核和程序的拷贝开销。针对需要遍历文件描述符才能知道哪个可读可写的问题,epoll使用的策略是在select和poll机制下:进程要亲自下场去各个文件描述符上等待,任何一个文件描述可读或者可写就唤醒进程,但是进程被唤醒后也是一脸懵逼并不知道到底是哪个文件描述符可读或可写,还要再从头到尾检查一遍。在epoll机制下进程不需要亲自下场了,进程只要等待在epoll上,epoll代替进程去各个文件描述符上等待,当哪个文件描述符可读或者可写的时候就告诉epoll,由epoll记录。
在epoll这种机制下,实际上利用的就是“不要打电话给我,有需要我会打给你”这种策略,进程不需要一遍一遍麻烦的问各个文件描述符,而是翻身做主人了——“你们这些文件描述符有哪个可读或者可写了主动报上来”。同步与异步
一、同步与异步场景:打电话与发邮件****1、同步
通常打电话时都是一个人在说另一个人听,一个人在说的时候另一个人等待,等另一个人说完后再接着说,因此在这个场景中你可以看到,“依赖”、“关联”、“等待”这些关键词出现了,因此打电话这种沟通方式就是所谓的同步。
2、异步另一种常用的沟通方式是邮件,因为没有人傻等着你写邮件什么都不做,因此你可以慢慢悠悠的写,当你在写邮件时收件人可以去做一些像摸摸鱼啊、上个厕所、和同时抱怨一下为什么十一假期不放两周之类有意义的事情。同时当你写完邮件发出去后也不需要干巴巴的等着对方回复什么都不做,你也可以做一些像摸鱼之类这样有意义的事情。
在这里,你写邮件别人摸鱼,这两件事又在同时进行,收件人和发件人都不需要相互等待,发件人写完邮件的时候简单的点个发送就可以了,收件人收到后就可以阅读啦,收件人和发件人不需要相互依赖、不需要相互等待。因此邮件这种沟通方式就是异步的。**二、编程中的同步调用****一般的函数调用都是同步的,就像这样:**123456funcA() { // 等待函数funcB执行完成 funcB(); // 继续接下来的流程}funcA调用funcB,那么在funcB执行完前,funcA中的后续代码都不会被执行,也就是说funcA必须等待funcB执行完成,如下图所示。
从上图中可以看出,在funcB运行期间funcA什么都做不了,这就是典型的同步。一般来说,像这种同步调用,funcA和funcB是运行在同一个线程中的,但值得注意的是即使运行在两个不能线程中的函数也可以进行同步调用,像我们进行IO操作时实际上底层是通过系统调用的方式向操作系统发出请求。
如上图所示,只有当read函数返回后程序才可以被继续执行。和上面的同步调用不同的是,函数和被调函数运行在不同的线程中。由此我们可以得出结论,同步调用和函数与被调函数是否运行在同一个线程是没有关系的。在这里需要再次强调同步方式下函数和被调函数无法同时进行。
三、编程中的异步调用有同步调用就有异步调用。一般来说异步调用总是和I/O操作等耗时较高的任务如影随形,像磁盘文件读写、网络数据的收发、数据库操作等。在这里以磁盘文件读取为例,在read函数的同步调用方式下,文件读取完之前调用方是无法继续向前推进的,但如果read函数可以异步调用情况就不一样了。假如read函数可以异步调用的话,即使文件还没有读取完成,read函数也可以立即返回。

如上图所示,在异步调用方式下,调用方不会被阻塞,函数调用完成后可以立即执行接下来的程序。这时异步的重点在于调用方接下来的程序执行可以和文件读取同时进行。值得注意的是异步调用对于程序员来说在理解上是一种负担,代码编写上更是一种负担,总的来说,上帝在为你打开一扇门的时候会适当的关上一扇窗户。有的同学可能会问,在同步调用下,调用方不再继续执行而是暂停等待,被调函数执行完后很自然的就是调用方继续执行,那么异步调用下调用方怎知道被调函数是否执行完成呢?这就分为调用方根本就不关心执行结果和调用方需要知道执行结果两种情况。
第一种情况比较简单,无需讨论。
第二种情况下就比较有趣了,通常有两种实现方式:1、通知机制
当任务执行完成后发送信号来通知调用方任务完成(这里的信号有很多实现方式:Linux中的signal,或使用信号量等机制都可实现);
**2、回调机制:**也就是常说的callback。
四、具体的编程例子中理解同步和异步以常见Web服务为例来说明这个问题。一般来说Web Server接收到用户请求后会有一些典型的处理逻辑,最常见的就是数据库查询(当然,你也可以把这里的数据库查询换成其它I/O操作,比如磁盘读取、网络通信等),在这里假定处理一次用户请求需要经过步骤A、B、C,然后读取数据库,数据库读取完成后需要经过步骤D、E、F。其中步骤A、B、C和D、E、F不需要任何I/O,也就是说这六个步骤不需要读取文件、网络通信等,涉及到I/O操作的只有数据库查询这一步。一般来说Web Server有主线程和数据库处理线程两个典型的线程。首先我们来看下最简单的实现方式,也就是同步。**这种方式最为自然也最为容易理解:**0102030405060708091011121314151617// 主线程main_thread() { A; B; C; 发送数据库查询请求; D; E; F;}// 数据库线程DataBase_thread() { while(1) { 处理数据库读取请求; 返回结果; }}主线程在发出数据库查询请求后就会被阻塞而暂停运行,直到数据库查询完毕后面的D、E、F才可以继续运行,这就是最为典型的同步方法。
如上图所示,主线程中会有“空隙”,这个空隙就是主线程的“休闲时光”,主线程在这段休闲时光中需要等待数据库查询完成才能继续后续处理流程。在这里主线程就好比监工的老板,数据库线程就好比苦逼搬砖的程序员,在搬完砖前老板什么都不做只是紧紧的盯着你,等你搬完砖后才去忙其它事情。
1、异步情况:主线程不关心数据库操作结果如下图所示,主线程根本就不关心数据库是否查询完毕,数据库查询完毕后自行处理接下来的D、E、F三个步骤。
一个请求通常需要经过七个步骤,其中前三个是在主线程中完成的,后四个是在数据库线程中完成的,数据库线程通过回调函数查完数据库后处理D、E、F几个步骤。伪码如下:12345void handle_DEF_after_DB_query () { D; E; F;}主线程处理请求和数据库处理查询请求可以同时进行,从系统性能上看能更加充分的利用系统资源,更加快速的处理请求;从用户的角度看,系统的响应也会更加迅速。这就是异步的高效之处。但可以看出,异步编程并不如同步来的容易理解,系统可维护性上也不如同步模式。
2、异步情况:主线程关心数据库操作结果如下图所示,数据库线程需要将查询结果利用通知机制发送给主线程,主线程在接收到消息后继续处理上一个请求的后半部分。
由此我们可以看到:ABCDEF几个步骤全部在主线中处理,同时主线程同样也没有了“休闲时光”,只不过在这种情况下数据库线程是比较清闲的,从这里并没有上一种方法高效,但是依然要比同步模式下要高效。但是要注意的是并不是所有的情况下异步都一定比同步高效,还需要结合具体业务以及IO的复杂度具体情况具体分析。高并发中的协程
协程是高性能高并发编程中不可或缺的技术,包括即时通讯(IM系统)在内的互联网产品应用产品中应用广泛,比如号称支撑微信海量用户的后台框架就是基于协程打造的。而且越来越多的现代编程语言都将协程视为最重要的语言技术特征,已知的包括:Go、Python、Kotlin等。
一、从普通函数到协程1234567void func() { print(“a”) 暂停并返回 print(“b”) 暂停并返回 print(“c”)}普通函数下,只有当执行完print(“c”)这句话后函数才会返回,但是在协程下当执行完print(“a”)后func就会因“暂停并返回”这段代码返回到调用函数。**我写一个return也能返回,就像这样:**1234567void func() { print(“a”) return print(“b”) 暂停并返回 print(“c”)}直接写一个return语句确实也能返回,但这样写的话return后面的代码都不会被执行到了。协程之所以神奇就神奇在当我们从协程返回后还能继续调用该协程,并且是从该协程的上一个返回点后继续执行。**就好比孙悟空说一声“定”,函数就被暂停了:**1234567void func() { print(“a”) 定 print(“b”) 定 print(“c”)}这时我们就可以返回到调用函数,当调用函数什么时候想起该协程后可以再次调用该协程,该协程会从上一个返回点继续执行。值得注意的是当普通函数返回后,进程的地址空间中不会再保存该函数运行时的任何信息,而协程返回后,函数的运行时信息是需要保存下来的。**二、“Talk is cheap,show me the code”**在python语言中,这个“定”字同样使用关键词yield。**这样我们的func函数就变成了:**1234567void func() { print(“a”) yield print(“b”) yield print(“c”)}这时我们的func就不再是简简单单的函数了,而是升级成为了协程,那么我们该怎么使用呢?很简单:
12345def A(): co = func() # 得到该协程 next(co) # 调用协程 print(“in function A”) # do something next(co) # 再次调用该协程虽然func函数没有return语句,也就是说虽然没有返回任何值,但是我们依然可以写co = func()这样的代码,意思是说co就是拿到的协程了。接下来调用该协程,使用next(co),运行函数A看看执行到第3行的结果是什么:1a显然,和预期一样协程func在print(“a”)后因执行yield而暂停并返回函数A。接下来是第4行,这个毫无疑问,A函数在做一些自己的事情,因此会打印:
12ain function A接下来是重点的一行,当执行第5行再次调用协程时该打印什么呢?如果func是普通函数,那么会执行func的第一行代码,也就是打印a。但func不是普通函数,而是协程,我们之前说过,协程会在上一个返回点继续运行,因此这里应该执行的是func函数第一个yield之后的代码,也就是 print(“b”)。
123ain function Ab三、图形化解释为了更加彻底的理解协程,我们使用图形化的方式再看一遍。首先是普通的函数调用:
在该图中方框内表示该函数的指令序列,如果该函数不调用任何其它函数,那么应该从上到下依次执行,但函数中可以调用其它函数,因此其执行并不是简单的从上到下,箭头线表示执行流的方向。从上图中可以看到:首先来到funcA函数,执行一段时间后发现调用了另一个函数funcB,这时控制转移到该函数,执行完成后回到main函数的调用点继续执行。这是普通的函数调用。接下来是协程:
在这里依然首先在funcA函数中执行,运行一段时间后调用协程,协程开始执行,直到第一个挂起点,此后就像普通函数一样返回funcA函数,funcA函数执行一些代码后再次调用该协程。三、函数只是协程的一种特例和普通函数不同的是,协程能知道自己上一次执行到了哪里。协程会在函数被暂停运行时保存函数的运行状态,并可以从保存的状态中恢复并继续运行。四、协程的历史协程这种概念早在1958年就已经提出来了,要知道这时线程的概念都还没有提出来。到了1972年,终于有编程语言实现了这个概念,这两门编程语言就是Simula 67 以及Scheme。但协程这个概念始终没有流行起来,甚至在1993年还有人考古一样专门写论文挖出协程这种古老的技术。因为这一时期还没有线程,如果你想在操作系统写出并发程序那么你将不得不使用类似协程这样的技术,后来线程开始出现,操作系统终于开始原生支持程序的并发执行,就这样,协程逐渐淡出了程序员的视线。直到近些年,随着互联网的发展,尤其是移动互联网时代的到来,服务端对高并发的要求越来越高,协程再一次重回技术主流,各大编程语言都已经支持或计划开始支持协程。
**五、协程到底如何实现?**让我们从问题的本质出发来思考这个问题协程的本质是什么呢?协程之所以可以被暂停也可以继续,那么一定要记录下被暂停时的状态,也就是上下文,当继续运行的时候要恢复其上下文(状态)函数运行时所有的状态信息都位于函数运行时栈中。如下图所示,函数运行时栈就是需要保存的状态,也就是所谓的上下文。
从上图中可以看出,该进程中只有一个线程,栈区中有四个栈帧,main函数调用A函数,A函数调用B函数,B函数调用C函数,当C函数在运行时整个进程的状态就如图所示。再仔细想一想,为什么我们要这么麻烦的来回copy数据呢?我们需要做的是直接把协程的运行需要的栈帧空间直接开辟在堆区中,这样都不用来回copy数据了,如下图所示。
从上图中可以看到该程序中开启了两个协程,这两个协程的栈区都是在堆上分配的,这样我们就可以随时中断或者恢复协程的执行了。进程地址空间最上层的栈区现在的作用是用来保存函数栈帧的,只不过这些函数并不是运行在协程而是普通线程中的。在上图中实际上共有一个普通线程和两个协程3个执行流。虽然有3个执行流但我们创建了几个线程呢?答案是:一个线程。使用协程理论上我们可以开启无数并发执行流,只要堆区空间足够,同时还没有创建线程的开销,所有协程的调度、切换都发生在用户态,这就是为什么协程也被称作用户态线程的原因所在。所以即使创建了N多协程,但在操作系统看来依然只有一个线程,也就是说协程对操作系统来说是不可见的。
这也许是为什么协程这个概念比线程提出的要早的原因,可能是写普通应用的程序员比写操作系统的程序员最先遇到需要多个并行流的需求,那时可能都还没有操作系统的概念,或者操作系统没有并行这种需求,所以非操作系统程序员只能自己动手实现执行流,也就是协程。六、协程技术概念小结****1、协程是比线程更小的执行单元协程是比线程更小的一种执行单元可以认为是轻量级的线程。之所以说轻的其中一方面的原因是协程所持有的栈比线程要小很多,java当中会为每个线程分配1M左右的栈空间,而协程可能只有几十或者几百K,栈主要用来保存函数参数、局部变量和返回地址等信息。我们知道而线程的调度是在操作系统中进行的,而协程调度则是在用户空间进行的,是开发人员通过调用系统底层的执行上下文相关api来完成的。有些语言,比如nodejs、go在语言层面支持了协程,而有些语言,比如C,需要使用第三方库才可以拥有协程的能力。由于线程是操作系统的最小执行单元,因此也可以得出,协程是基于线程实现的,协程的创建、切换、销毁都是在某个线程中来进行的。使用协程是因为线程的切换成本比较高,而协程在这方面很有优势。**2、协程的切换到底为什么很廉价?**关于这个问题,回顾一下线程切换的过程:1)线程在进行切换的时候,需要将CPU中的寄存器的信息存储起来,然后读入另外一个线程的数据,这个会花费一些时间;
2)CPU的高速缓存中的数据,也可能失效,需要重新加载;
3)线程的切换会涉及到用户模式到内核模式的切换,据说每次模式切换都需要执行上千条指令,很耗时。
实际上协程的切换之所以快的原因主要是:1)在切换的时候,寄存器需要保存和加载的数据量比较小;
2)高速缓存可以有效利用;
3)没有用户模式到内核模式的切换操作;
4)更有效率的调度,因为协程是非抢占式的,前一个协程执行完毕或者堵塞,才会让出CPU,而线程则一般使用了时间片的算法,会进行很多没有必要的切换。