计算机底层2 程序在运行时发生了什么

2023年 9月 25日 23.6k 0

在上一篇文章中,主要介绍了如何从编程语言一步步到可执行程序,介绍了编译器,链接器,虚拟内存,抽象的概念。在这篇文章中,将要把重点放在程序运行起来之后,来介绍程序运行的时候发生了什么,其中就有在大学的操作系统课上耳熟能详的进程,线程,携程,同步,异步,阻塞,非阻塞的概念,还会介绍高并发,高性能的服务器是如何实现的。

1 CPU 在干什么?

我们在上一篇文章中介绍到:

CPU是个笨蛋,笨到只会把数据从一个地方搬到另外一个地方,进行简单的计算后,再把数据搬回去。

CPU 只知道两件事情:

  • 从内存中取出指令
  • 执行指令,再回到第一步
  • 那么在第一步,CPU 根据什么来取出指令呢,答案是PC寄存器,也就是程序计数器。寄存器可以理解是一种内存,但是速度更快,容量也更小,关于内存的详细内容,也将会在下一篇文章中介绍。

    PC寄存器中存放的是指令在内存中的地址,是CPU 将要执行的下一条指令。PC 寄存器的初始值来自内存,内存中的指令是从磁盘中保存的可执行文件里加载过来的,而磁盘中的可执行文件是由源文件通过编译器,链接器生成的,这又回到了上一章介绍的知识。

    除此之外,PC寄存器的地址默认加1,也就是默认情况下,CPU 按顺序一条一条执行指令,如果遇到了if-else 或者函数调用时,PC寄存器的只也会根据CPU 的执行结果动态改变里面要跳转的地址。

    image.png

    我们都知道,main 函数是程序的入口,程序启动时,会先找到main 函数的对应的第一条机器指令,然后将其地址写入PC寄存器,这样CPU 就会开始运行这条指令,再一条条执行下去,程序就运行起来了。

    在上面的过程中,我们发现,如果想让CPU 运行程序,就要:

  • 在内存中找到一块大小合适的区域装入程序
  • CPU 寄存器初始化后,找到函数的入口,设置PC寄存器
  • 可以看到,把程序加载到内存,这是需要手工完成的,步骤繁琐,重复性高,而且,一次性只能运行一个程序,效率极低,无法支持多任务编程,同时,要给每个程序手动链接硬件驱动。

    但是我们发现,现在的程序员好像并没有去手动把程序加载到内存,多任务编程非常常见,也不需要给每个程序安装硬件驱动,那是谁帮我们做了上面的这些事情呢?答案就是现代的操作系统。

    2 操作系统

    总结我们上面提到的一些我们不希望手动解决,但是希望操作系统这个“程序”能够完成的事情:

  • 自动加载程序
  • 实现多任务功能的进程管理
  • 管理软硬件资源,为程序提供服务
  • 自从操作系统诞生后,程序员再也不用手动加载可执行文件,也不用手动维护程序的运行,一切交给操作系统即可。

    2.1 进程

    我们常说:程序进程,就是因为在程序运行起来的时候,程序以进程的方式被管理起来,一个程序就是一个进程,我们上面说,操作系统帮助我们实现多任务功能的进程管理,这里的”多任务管理“,是不是就是同时运行多个程序进程呢?要怎么实现呢?

    2.1.1 多任务编程

    我们知道,CPU 一次只能做一件事情,那要怎么让进程A 和进程B 同时运行呢?

    还记得在上一篇文章中,我们提到,CPU 很笨,但是它有一件人脑难以超越的绝对优势:快。因为快,CPU 就可以做到在几个线程中”来回穿梭“,先运行一会进程A,再马上跳去运行进程B,只要CPU 足够快,看起来就是进程A 和进程B 同时在运行,也就是多任务编程。

    image.png

    当然,上面是单核CPU 的情况,如果是多核,那么每个CPU 都能得到充分利用,每个CPU 都能执行任务,实现并发。

    image.png

    2.1.2 多进程的好与坏

    在上一篇文章展示了进程的内存地址空间分布:

    image.png

    可以看到,每个进程都有自己的进程地址空间,进程间是独立的。

    这就是多进程编程的其中一个优点:各个进程的地址空间相互隔离,因此一个进程崩溃后,不会影响到其他进程。

    但是这个优点同时也是多线程编程的缺点。

    现在假设我们有两个函数A和B。分别交给进程A 和进程B 去执行,最后再把在进程B中执行完成的函数B 的运行结果加到进程A 中,可以知道,这个场景涉及到了进程间的通信。

    但是我们刚刚说了,每个进程都有自己的进程地址空间,进程间是独立的,如果跨进程进行编程,那么在编程上是比较复杂的。

    稍微总结一下:

    多线程编程的优点:

  • 编程简单,易于理解
  • 各个进程的地址空间相互隔离,因此一个进程崩溃后,不会影响到其他进程
  • 能够充分利用多核资源
  • 缺点:

  • 各个进程都有自己的地址空间,相互隔离,进程间通信在编程上比较复杂
  • 创建线程的开销大,如果频繁地创建,销毁线程无疑会加重系统的负担
  • 因此,有了线程。

    2.2 线程

    线程能让多个CPU 来执行同一个进程中的机器指令。

    image.png

    这样,就不存在进程间通信的开销了,因为都是在同个进程中。

    有了线程的概念,我们只需要开启一个进程,并且创建多个线程,就可以让所有的CPU 都工作起来,充分利用多核,这才是高并发,高性能的根本所在。

    当然,并不是只有多核才能进行多线程,单核同样可以,因为线程是操作系统层面的实现,和有多个核心是没有关系的。在单核中,就是像上面多任务编程一样,CPU 在不同的线程之间切换执行,从而伪并行地执行多个任务。

    举个例子,如果有一个任务A,耗时3分钟,还有一个任务B,耗时4分钟,如果有线程A 和线程B 分别执行任务A 和任务B,如果在多核中,总耗时取决于耗时最长的那个任务(任务B),所以耗时4分钟。但如果是单核,单个CPU 在两个线程中切换执行任务,总耗时还是3 + 4 = 7分钟。
    当今大部分的计算机,移动设备都是多核系统。

    2.2.1 多线程的内存布局

    我们知道,函数在执行时,所依赖的信息,包括函数参数,局部变量,返回值等信息,都被保存在相应的栈中。

    在有了多线程后,一个进程中就有多个程序的入口,也就是说,有多个执行流,那么,这个进程中也不应该只有一个栈帧,应该是每个线程(执行流)都有属于自己的栈帧。

    image.png

    2.2.2 线程池

    线程虽然方便,但是如果来一个请求或者任务就创建一个线程,特别是对于比较容易,耗时短的任务,就会有以下的缺点:

  • 大量的创建和销毁线程是耗时的
  • 每个线程都会有自己独立的栈区,当创建大量的线程时,会消耗过多的内存等系统资源
  • 大量线程之间的切换使得线程间切换的开销增加
  • 因此,为了避免这些因为反复多次创建,销毁线程带来的开销和性能的下降,产生了线程池。

    线程池的概念很简单,就是创建一批线程,有任务就交给它们处理,因此不需要频繁的创建和销毁。其实,也就是复用的思想。

    2.3 线程间共享资源

    在大学的操作系统课上,我们已经把进程和线程的概念和它们之间的关系背的滚瓜烂熟了:进程是资源调度的最小单位,线程是CPU 调度的最小单位,一个进程可以包含多个线程,进程是线程的容器,线程是进程的实际执行者,线程之间共享进程资源。

    那么,线程到底共享了哪些进程的资源呢?

    首先,我们来看看什么是线程私有的资源:

    2.3.1 线程私有资源

    函数的运行时信息保存在栈帧中,栈帧组成了栈区,栈帧中保存了函数的返回值、函数参数、返回值、局部变量以及该函数使用的寄存器信息。

    image.png

    此外,CPU 执行机器指令时其内部寄存器的值也属于当前线程的执行状态:

    如PC 寄存器,保存的是下一条被执行指令的地址,栈指针,指向着栈顶。这些寄存器信息也是线程私有的。

    所以,线程的栈区,PC 寄存器,栈指针,执行函数使用的寄存器信息,都是线程私有的。

    2.3.2 线程共享资源

    2.3.2.1 代码区

    我们在第一篇文章的时候说过,代码区存放的是程序员写的代码生成的机器指令,线程之间共享代码区,所有的线程都可以执行代码区的代码。

    除此之外,代码区的代码指令在编译完成后,就是只读(read only) 的,任何线程都不能修改代码区的代码,因此,尽管代码区可以被所有进程内的线程共享,但是不会出现线程安全问题。

    2.3.2.2 数据区

    数据区存放的是全局变量,同样,所有的线程都能访问到。

    2.3.2.3 栈区

    我们刚刚才提到,线程有自己的栈区,这是线程私有的,但是事实上,同一个进程的线程栈帧并不是像进程与进程一样互相隔绝地址空间的。因此,如果一个线程能够拿到来自另外一个线程的指针,那么该线程可以直接读写另外一个线程的栈区。

    image.png

    这种线程间没有保护的机制有时候便利了线程间通信,但是有时候会带来难以觉察的bug,因为一个线程有可能会无意间修改了属于其他线程的私有数据,而且不知道什么时候会爆雷,有可能其他线程正在平稳运行,突然就出bug,或者崩溃,这个时候,出现问题的代码距离真正的bug 可能已经很远了。

    2.3.2.4 动态链接库与文件

    我们在上一篇文章中介绍了在生成可执行文件中的静态链接和动态链接。

    如果是静态链接,那么在程序加载前,所依赖的库就已经全部打包到可执行程序中,这类程序在启动时不需要额外的工作。

    而动态链接是可执行程序中并不包含依赖库的代码和数据,当程序加载或者运行时,才完成链接过程,也就是先找到所依赖库的代码和数据,然后放到进程的地址空间中。

    那么放到进程地址空间的哪里呢?

    答案是放到了栈区和堆区中间的那部分空闲区域中:

    image.png

    这一部分的地址空间也是被所有的线程共享的。

    除此之外,如果程序在运行过程中打开了一些文件,那么进程地址空间中还保存有打开的文件信息,进程打开的文件信息也可以被所有的线程使用,也成为了线程的共享资源。

    2.3.3 线程局部存储 TLS

    线程局部存储是指一个变量在每个线程中都有一个副本。

    存放在该线程区域的变量有两个含义:

  • 可以被所有线程访问到
  • 虽然所有线程都可以访问到这个变量,但这个变量只属于一个线程,一个线程对这个线程的修改对其他线程是不可见的
  • 假如现在有这样一段代码:

    int a = 1;
    
    void func() {
        a++;
        printf("%dn", a);
    }
    
    void main() {
        thread t1(run);
        t1.join();
        
        thread t2(run);
        t2.join();
    }
    

    很容易想到,这个运行结果应该是:

    2
    3
    

    因为两个线程都能共享数据区的资源,线程t1 先读写,a = 2,线程t2 后读写,a = 3

    但是如果使用TLS,即int a = 1; 变成__thread int a = 1; 这样程序的运行结果会变成这样:

    2
    2
    

    这就是刚刚提到的:TLS 线程局部存储的作用:使这个变量在每个线程中都有一个副本,使得这个线程对变量对其他线程不可见。

    image.png

    了解线程私有资源和共享资源是为了写出线程安全的代码。

    2.4 线程安全

    为什么在买火车票时,几万部手机同时购票,也不会出现一张票被好几部手机同时抢到的情况呢,为什么每一部手机都能正确显示剩余的票数呢?这就是线程安全。

    刚刚我们介绍了线程的私有资源和共享资源,很明显,线程的私有资源能够实现线程安全,如果是共享资源,在不影响其他线程的约束下,也能实现线程安全。就比如代码区是只读read only 的,就不影响其他线程,就像在卖高铁票的时候,如果只是查看剩余的票数,就是线程安全的。

    这样的话,我们容易知道,有线程不安全风险的就只有堆区和数据区的数据。

    堆区是用于动态分配内存,也就是malloc/new/alloc这样在堆区申请的内存。数据区存放的是全局变量。

    那么,应该如何实现线程安全呢?

  • 使用线程局部储存TLS

    刚刚也说明了,TLS 是一个变量在每个线程中都有副本,一个线程对变量的改变对其他线程是不可见的。所以TLS 是线程安全的。

  • 只读readonly

    如果需要使用全局变量,那是不是可以让这种资源只是可读的?readonly不会产生线程安全问题。

  • 原子操作atomic

    一个操作在执行过程中不会被中断或干扰,要么执行完毕,要么根本不执行,不存在中间状态。

  • 同步互斥

    在这一步,程序员不得已手动维护线程访问共享资源的秩序,比如互斥锁,自旋锁,信号量和其他同步互斥机制都可以达到这个目的。

  • 自旋锁

    如果共享的数据先被其他线程使用了,那么该线程就会以死循环的方式等待锁,一旦访问的资源被解锁,则等待的线程会被立即执行

    自旋锁适合于:

  • 预计等待的时间很短
  • 临界区(加锁的资源区)经常被调用,但是竞争情况很少发生
  • 多核处理器
  • CPU 的资源不紧张
  • 互斥锁
    与自旋锁不同,在互斥锁的情况下,如果共享的数据被其他线程占用了,那么该线程就会以休眠的方式等待锁,一旦访问的资源被解锁,则等待资源的线程就会被唤醒

    自旋锁适合于:

  • 预计等待时间很长
  • 临界区竞争激烈
  • 单核处理器
  • CPU 资源紧张
  • 信号量

    在Objective-C 的GCD(iOS 开发中用于多线程编程的比较好的方案),dispatch_semaphore 是一个用于实现信号量(Semaphore)的API。在dispatch_semaphore 中,使用计数来完成这个功能

    计数小于 0 时需要等待,不可通过。

    计数为 0 或大于 0 时,不用等待,可通过。

    计数大于 0 且计数减 1 时不用等待,可通过。

    通过加减计数,达到加锁(不可通过)和解锁(可通过)的效果

    到目前为止,线程的创建,销毁,调度,都是由操作系统帮我们完成的,那么我们可以在不依赖操作系统的情况下自己实现线程吗?

    协程可以做到。

    3 协程

    协程和普通函数在形式上没有差别,只不过协程有一项和线程很相似的本领:暂停与恢复。

    CPU 之所以能够在线程之间切换,执行多任务编程,靠的就是能够先暂停一个线程,再恢复它的运行,这其中最重要的就是记住线程的状态,便于后续恢复执行。

    这里用于“记住”线程状态的就是上下文Context,当一个线程被暂停了,就会把它目前的状态保存到Context 中,后面再根据Context 来恢复程序的运行。

    那么协程能够做到相似的本领:能够保存自身的执行状态,从协程返回后还能从上一个暂停点(挂起点)继续执行。

    而函数如果要暂停,就只能return,但是return后面的代码就再也无法被执行了。

    就比如在python 中,使用yield来实现协程:

    def func():
      print("a")
      yield
      print("b")
      yield
      print("c")
    

    使用:

    def funcUse():
      co = func() # 得到该协程
      next(co)    # 调用该协程
      print("in functionA") # 完成其他操作
      next(co)    # 继续该协程
    

    调用结果:

    a
    in functionA
    b
    

    可以看到协程能够做到暂停与恢复。

    让我们用图的方式查看函数与协程的区别:

    image.png
    image.png

    可以看到,funcA 函数在运行了一段时间后,调用协程,协程开始运行,直到第一个挂起点,随后返回到funcA,funcA 在运行一段时间后,再次调用协程,此时,协程从上次的挂起点之后开始执行而不是从头开始,直到第二个挂起点,随后再次返回到funcA。

    这个过程,就像是操作系统对线程的调配,有了协程,程序员也可以扮演操作系统类似的角色了,协程的调度权在程序员自己手上。除此之外,其实,函数也只不过是没有挂起点的协程而已。

    协程的技术比线程更早出现,后来有了线程,直接由操作系统调配,更加方便,协程逐渐淡出程序员的视线。在近几年,尤其是移动互联网时代的到来,服务端需要处理大量的用户请求,程序员发现协程在实现高并发,高性能的服务器上有着优势,所以,协程再一次回到程序员的视线中。

    4 回调函数

    回调函数是指一段以参数的形式传递给其他代码的可执行代码。

    比如:

    void funcOther(func f) {
        ...
        f();
        ...
    }
    

    4.1 同步调用和异步调用

    在一般情况下,程序员调用函数的思维是这样的:

  • 调用某个函数,获取结果
  • 处理获得的结果
  • res = request();
    handle(res);
    

    这就是函数的同步调用。

    但是如果,我们把函数以参数传递给调用的函数,在调用的函数中处理,就像这样:

    request(handle);
    

    这样,我们不关心handle函数什么时候才会被调用,这是request 需要关心的。

    这两种方式用图这样表达:

    image.png

    4.2 同步回调和异步回调

    首先是同步回调,或者叫阻塞式回调,这是我们最熟悉的回调方式。假设要调用函数A,并传递了回调函数作为参数,那么在函数A 返回前,回调函数会被执行。

    异步回调,或者叫延迟回调。那么异步回调和同步回调不同的是,在调用完函数A 后,函数A 的调用会立刻完成,主线程就开始完成其他任务,一段时间后,回调函数开始执行,也就是说,在异步调用下,主线程和回调函数的执行可能在同时进行。一般情况下,主线程和回调函数的执行位于不同的线程或者进程中。

    我们在写项目时,经常会用到第三方库,我们有时候就会以回调函数的方式利用第三方库:

    我们给第三方库指定回调函数,因为第三方库的编写方并不知道我们要在某些特定节点应该实行什么操作,所以就无法针对具体实现来编写代码,所以会对外提供一个参数,而由我们使用方来实现函数并把它作为参数传递给第三方库,第三方库只需要在特定的时间节点去调用该回调函数即可。

    比如,在网络请求接收网络数据,文件传输完成后这样的时间节点,我们希望能调用一段函数来处理或者通知,这个时候回调函数就可以派上用场,因此,回调函数也适合于事件驱动型编程。

    image.png

    从图中可以看出,异步回调要比同步回调更能充分利用多核资源,同步回调会有一段“空闲时间”,而异步回调则是CPU 一直在干活,能够充分利用CPU 资源。

    5 同步与异步

    5.1 同步调用

    同步调用是我们比较熟悉的调用方式,比如:

    void funcA() {
        ...
        funcB();
        ...
    }
    

    函数A 调用函数B,在函数B 完成前,函数A 后面的代码都不会执行。如图:

    image.png

    一般来说,同步调用都运行在同一个线程中,但是有一些特殊操作,比如I/O 操作,使用read来读取文件,这个时候,就是在内核的线程中执行读取文件操作的。当然,这也是同步调用,不过不属于同一个线程。

    不过我们也可以看出,同步调用虽然易于编程,但是并不高效,因为调用方需要等待。

    5.2 异步调用

    异步调用不会阻塞调用方,而且一般会开启新的线程。

    但是在异步调用的情况下,我们如何才能得知执行结果并且处理结果呢?

    这就分成了两种情况:

  • 调用方不关心调用结果
  • 调用方需要知道执行结果
  • 第一种情况的实现方法可以利用刚刚讲到的异步回调:

    image.png

    第二种情况的实现方法是利用通知机制,也就是任务完成后,发送信号或者消息来通知调用方任务完成。

    image.png
    6 阻塞与非阻塞

    刚刚我们说的同步与异步,是多任务处理的方式,也就是说,同步与异步不仅出现在计算机科学领域,别的领域比如通信也有这个概念。

    而我们现在要说的阻塞与非阻塞,在编程语境中通常用在函数调用上。

    6.1 阻塞式调用

    假设现在有两个函数A 和B,函数A 调用函数B,当函数A 的线程因调用函数B被操作系统挂起暂停运行时,我们就说函数B 的调用是阻塞式的。

    image.png

    可以看到,阻塞式调用的关键在于线程或者进程被暂停运行。

    那么在什么情况下,会因为调用函数导致线程被操作系统暂停运行呢?

    一般情况下,阻塞几乎都与I/O 操作有关。

    6.2 阻塞与I/O 操作

    以磁盘为例,我们都知道磁盘寻道的I/O 请求耗时比CPU 的工作频率要低得多,CPU 能在这个期间执行大量机器指令,如果是阻塞式I/O 请求,那么CPU 时间就被浪费了,因此,在该线程或者进程涉及到I/O 操作时,就应该把CPU 时间从该进程上拿走,去分配给其他可以运行的线程或者进程,当I/O 操作完成后,再将CPU 再次分配该线程或者进程,在此之前,该线程或者进程一直是被阻塞而停止运行的。

    image.png

    那有没有一种方案既能够发起I/O 操作,又不会导致调用线程被暂停运行呢?

    非阻塞式调用可以。

    6.3 非阻塞式调用

    非阻塞式调用有几种方式可以实现:

  • 使用结果查询函数
    通过调用结果查询函数,我们可以知道是否接收到了数据
  • 通知机制
    就像刚刚提到的异步调用,接收到数据后,通知调用的线程
  • 回调函数
    把收到数据后对数据的处理逻辑封装成回调函数,在被调用的线程中调用回调函数
  • 6.4 同步与阻塞

    刚刚我们说过,同步异步是多任务处理的方式,而阻塞与非阻塞是函数调用的方式。

    也就是说,同步不一定是阻塞的(但是阻塞一定是同步的)

    同步不一定是阻塞的:有可能在一个线程中有funcA 函数,它同步调用函数B:

    void funcA() {
        ...
        funcB();
        ...
    }
    

    image.png

    但是,这并不意味着funcA 所在的线程被阻塞而暂停运行,但是,如果一个函数是阻塞式调用的,那肯定是同步的。

    6.5 异步与非阻塞

    非阻塞不一定就意味着是异步的。

    举个例子,假如有这样一个函数funcR,是非阻塞调用的,用于获取网络数据,还有一个函数handle,用于处理funcR请求的来的网络数据,还有一个函数check,用于检测funcR是否有网络数据传来。

    如果要写一个异步非阻塞功能的代码,只需要创建两个线程,一个线程用于执行funcR函数来获取网络数据。另一个线程中使用一些事件驱动的机制,如回调函数、消息队列、等,来等待网络数据的到达,在有数据时调用handle函数来处理数据。在funcR函数来获取网络数据期间,该线程还可以做别的事情。

    image.png

    但是事实上,非阻塞并不意味着是异步的

    比如这样稍微改动:还是创建两个线程,一个线程用于执行funcR函数来获取网络数据。另一个线程中使用check函数来事件循环来检测funcR是否有网络数据传来,并在有数据时调用handle函数来处理数据。

    也就是:

    while (true) {
        funcR(res);  // 获取网络数据,调用后直接返回,不阻塞,获取后的数据放入res
        while (!check()) {  // 循环检测
            ...
        }
        handle(res);  // 处理网络结果
    }
    

    可以看到,我们用了while循环不断检测到底有没有网络数据传来,该线程中并没有执行其他任务,CPU 一直浪费在while循环中,这样的代码非常低效。实际上还是同步,所以上面这段代码是同步非阻塞的。

    所以,同步并不意味着阻塞,非阻塞也不意味着是异步的,要看具体的代码实现。同步与异步是多任务处理上的,阻塞与非阻塞是函数调用上的,阻塞与非阻塞的关键是要看线程或者进程是否被堵塞。

    7 高并发,高性能的服务器是如何实现的

    我们目前学了进程,线程,协程,回调函数,同步,异步,阻塞,非阻塞,这些技术合理利用,可以让我实现高并发,高性能的服务器。

    7.1 事件循环与事件驱动

    到目前为止,提到“并行”,就会想到进程和线程,但是事实上,并行编程并不只有这两项技术,在服务器编程中,还有事件驱动型编程。

    事件驱动编程技术需要两种东西:

  • 事件(event),比如网络数据到来,文件是否可读
  • 处理事件的函数(handler())
  • 这个过程可以简单成:事件event 源源不断到来,当事件到来后,检查一下事件的类型,并根据该类型找到对应的事件处理函数handle(),然后直接调用。

    image.png

    但是,我们要需要解决两个问题:

  • 事件来源问题
  • 处理事件的handler 函数要不要和事件循环函数同一个线程?
  • 7.2 事件来源与I/O 多路复用,多线程

    一切皆文件,我们的程序都是通过文件描述符来进行I/O 的,我们应该如何同时处理多个文件描述符呢,这就有了I/O 多路复用机制,比如Linux 中的epoll,也就是告诉epoll需要处理的一些文件描述符号,如果事件发生,就进行处理。

    epoll是为事件循环而生的,I/O 多路复用技术就成为了事件循环的发动机,源源不断地提供事件,这样,事件来源的问题就解决了。

    image.png

    如果事件处理函数具备以下特点:

  • 不涉及I/O操作
  • 处理函数比较简单,耗时少
  • 那么这个时候我们可以让事件循环函数和事件处理函数放在同一个线程中。这种情况下,请求是串行处理的,那么如果处理用户请求需要消耗大量的CPU 时间呢?

    这个时候,就应该采用多线程并行处理。

    image.png

    那如果在处理请求的过程中,同时涉及I/O 操作,这里就需要注意,在事件循环中,一定不能调用任何阻塞式接口,否则会导致事件循环线程被暂停执行,这个时候,事件循环这台发动机就熄火了,整个系统都不能继续向前推进,但是可以把阻塞式I/O 调用的任务交给工作线程,即使某个工作线程被堵塞,也不会妨碍其他工作线程。

    7.3 协程:以同步的方式进行异步编程

    前面说过,协程最大的特点就是能够暂停与恢复,而且协程挂起后,并不会阻塞工作线程,当协程被挂起后,工作线程将转去执行其他准备就绪的协程,当完成请求返回处理结果后,主动暂停的协程将再次具备可执行的条件,并且等待调度执行,此后该协程会在上一次挂起点继续运行下去。

    image.png

    所以增加协程后,服务器的事件循环环节接收到请求后,将handle方法封装成协程并且分发给各个工作线程,供他们调度执行,工作线程拿到线程后,开始执行其入口函数,也就是handler函数,当某个协程因为RPC请求主动释放CPU 后,该工作线程将去找到下一个具备运行条件的协程,这样,在协程中发起阻塞式RPC 调用就不会阻塞工作线程,达到高效利用CPU 资源的目的。

    image.png

    CPU,线程,协程是在不同层面上的:CPU 执行机器指令驱动计算机运行,而线程是内核创建调度的,线程是CPU 资源调度的最小单位,而协程对内核来说是不可见的,内核是按照线程来分配CPU 时间片的,在线程被分配到的时间片内,程序员可以自行决定运行哪些协程。

    image.png
    image.png

    协程本质上是线程CPU时间片在用户态的二次分配,因此,协程也被称为用户态线程。

    8 虚拟化技术

    在上一篇文章中,我们提到抽象是计算机中非常重要的概念,内存虚拟化让每个进程都认为自己独占一整块内存,而CPU 也可以虚拟化,CPU 的虚拟化让每个进程认为自己独占CPU。

    虚拟化技术是一种计算机技术,它允许在一台物理计算机上创建多个虚拟环境,每个虚拟环境都可以运行独立的操作系统和应用程序。这些虚拟环境被称为虚拟机(VMs),它们是在物理硬件上的软件仿真,为用户提供了一种将多个虚拟计算机运行在同一台物理计算机上的方式。

    这也就是CPU 以及操作系统被抽象成了虚拟机。

    9 总结

    在这一篇文章中,我们了解到了操作系统,进程,线程,协程,回调函数,同步,异步,阻塞,非阻塞的概念,也因此了解到了高并发,高性能的服务器的基本架构。

    10 参考资料

    • 陆小风. 计算机底层的秘密. 电子工业出版社, 2023.
    • 计算机底层的秘密 gitbook

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论