美文网首页
进程、线程和协程的深度剖析

进程、线程和协程的深度剖析

作者: PENG先森_晓宇 | 来源:发表于2023-07-30 17:23 被阅读0次

    背景

    最近在看虚拟内存相关的知识,看到很多之前对于进程、线程、协程之间的一些盲区。

    之前其实对进程、线程的认识比较浅显,就知道进程是资源分配单位,线程是系统调度单位,具体是什么意思,还真不知道,我相信很多小伙伴也是只停留在这个基础阶段,那么你看完本篇文章之后,你一定会对进程、线程、协程有一个全新的认识的。

    上面提到了虚拟内存的概念,这里先接单介绍一下,详细的可自行查阅资料。

    通常一个程序的运行,需要程序员把代码写好,然后编译成二进制文件,并存储在磁盘中,该二进制该文件保存机器可运行的机器码、全局变量等信息,这些信息在二进制文件中也是分段存储的。

    当进程运行时,需要将二进制文件中的这些信息都映射到内存中,这里的内存指的就是是虚拟内存了,而不是真正的物理内存。

    进程在执行时会申请独立的虚拟内存以及页表(用来映射虚拟内存和物理内存的),此时还不是申请正在的物理内存,只有在对这块虚拟内存读写时,cpu查看是否已经关联物理内存。如果已经关联则直接返回,如果没有则发出缺页中断,此时会尝试在物理内存中申请此次进程足够的内存,如果物理内存足够则直接返回;若物理内存不够,此时操作系统会进行内存交换(前提是开启swap机制),如果交换之后内存还是不够,操作系统则会放最后的大招OOM KILLER,此时会遍历系统的所有进程,选择进程占用页数最高的进程kill掉,其实OOM KILLER会调用一个oom_badness()函数,该函数如下

    points = process_pages + oom_score_adj*totalpages/1000
    

    可以看出其实操作系统是对所有的进程计算一个points值,值越高,越容易被kill,oom_score_adj校准值是通过它是可以通过 /proc/[pid]/oom_score_adj来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率,也就是说配置的校准值越高,越容易被杀死。

    其实上面只是介绍了一点内存相关的知识,还有很多需要你们自己去了解,实在是太多了,这里就不介绍了

    • 为什么会出现虚拟内存
    • 虚拟内存的内存模型结构
    • 内存的分段、分页模型的区别
    • 内存交换的swap机制
    • 内存共享(下面会介绍)
    • 内存的写时复制机制(下面会介绍)
    • 内存的linux布局

    等等,这块知识太多了

    介绍了内存的背景知识后,我们言归正传,开始介绍进程、线程、和协程的相关知识。

    进程

    进程是资源分配的基本单位,为什么这么说呢?

    因为每个进程的运行都需要申请内存、文件等资源,上面已经介绍过了会申请虚拟内存页表物理内存,如果程序读取文件了,还需要文件资源,可以看出进程的运行确实需要一大堆资源。

    可以发现每个进程都有需要申请一大堆资源,也就是说进程间的资源时独立的,不共享的,其实这句话只针对于独立的多个进程间,而对于父子进程来说此话就有点不太对了,具体下面介绍,页埋个伏笔。

    在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程,接着线程就变为调度的基本单位了。

    进程的状态

    我们知道了进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。

    它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。


    • 运行状态(Running):该时刻进程占用 CPU;
    • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
    • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
    • 创建状态(new):进程正在被创建时的状态;
    • 结束状态(Exit):进程正在从系统中消失时的状态;

    如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。

    所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。

    那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。

    另外,挂起状态可以分为两种:

    • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
    • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

    进程sleep 10s,会准时在10s被唤起吗

    当一个进程执行过程中遇到 sleep 10s,它会从运行状态转换为阻塞状态,并进入阻塞挂起状态,等待指定的时间(10秒)过去后再次进入就绪状态,等待被调度执行。

    在多任务操作系统中,如果有多个进程处于就绪状态,操作系统的调度器会从中选择一个进程来执行,通常使用调度算法来决定选择哪个进程。调度器的选择可能受到各种因素的影响,比如优先级、进程的先后顺序等。

    确实,在选择执行一个进程时,可能会出现一种情况,即上面sleep 10s的进程在下次被调度执行的时候,时间已经超过10秒了。这是因为在多任务环境下,其他就绪状态的进程可能会优先获得CPU资源执行,从而导致原本被阻塞的进程等待更长的时间才被调度执行。

    这个现象是正常的,且在多任务操作系统中是常见的情况。操作系统的调度算法通常会尽量平衡资源分配,但无法保证每个进程都能按照严格的时间表执行,因为进程的调度是受到多个因素影响的动态过程。

    因此,在编写程序时,特别是需要严格控制时间的场景,应该考虑其他更可靠的时间控制方式,而不是依赖于 sleep 函数。例如,可以使用定时器或其他时间管理机制来确保程序在指定的时间内执行特定的操作。

    内存模型

    那么进程执行后的,虚拟内存长什么样子呢?就是这个下面这个样子了。

    在32位系统中虚拟内存最大申请2^23 = 4G内存,内核空间占用1G,用户空间占用3G。也就是说在32位的系统中进程能最大申请3G的内存,超过3G则会申请失败。

    可以看到堆地址是由地址向高地址扩展的,文件映射于匿名映射区是从高地址到地址地址扩展的,中间有一个待分配区域,也就是说初始时堆地址和文件映射于匿名映射区时不连续的。

    栈区也是由高地址向地址扩展的,栈区和文件映射于匿名映射区中间也是由一个待分配区域。

    在64位系统中,内核空间占用128T,用户空间占用128T。


    注意:进程的虚拟地址中的这些所有段都是私有的不可共享的,只属于本进程本身,其他进程(除了父子进程)是不可以读到该进程虚拟内存中的任何段任何数据的。

    对于父子进程而言,子进程是继承父进程的内存资源的,所以上面说的私有是针对独立的进程而言的,对于子进程是继承了父进程的哪些内存资源,而哪些资源又是不能继承的,下面的段落会详细介绍。

    代码段

    首先我们会想到的是一个进程运行起来是为了执行我们交代给进程的工作,执行这些工作的步骤我们通过程序代码事先编写好,然后编译成二进制文件存放在磁盘中,CPU 会执行二进制文件中的机器码来驱动进程的运行。所以在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段。

    数据段

    在程序运行起来之后,总要操作变量吧,在程序代码中我们通常会定义大量的全局变量和静态变量,这些全局变量在程序编译之后也会存储在二进制文件中,在程序运行之前,这些全局变量也需要被加载进内存中供程序访问。所以在虚拟内存空间中也需要一段区域来存储这些全局变量。

    那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段。

    BSS段

    那些没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。这些未初始化的全局变量被加载进内存之后会被初始化为 0 值。

    堆段

    上面介绍的这些全局变量和静态变量都是在编译期间就确定的,但是我们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆。注意这里的堆指的是 OS 堆并不是 JVM 中的堆。

    堆段的地址是由 低地址向高地址扩展的。

    文件映射和匿名映射区

    文件映射和匿名映射其实是俩个不同的概念,只是放在了同一个区而已。

    文件映射和匿名映射区的地址是由高地址向低地址扩展的,文件映射和匿名映射区和堆之间不是连续的,有一块待分配区域,用于堆和文件映射和匿名映射区扩展。

    文件映射

    • 文件映射允许进程将一个文件或者文件的一部分映射到自己的虚拟地址空间中,使得进程可以通过访问虚拟内存地址来读写文件数据,就好像内存中直接存在该文件数据一样。
    • 当进程对映射区进行读写操作时,实际上是对磁盘上的文件进行操作。这种方式非常高效,因为文件的读写操作是由操作系统在后台处理的,而不需要程序员显式地进行读写文件操作。
    • 操作系统会使用mmap(在Linux/Unix系统中)或类似的系统调用,将文件从磁盘上映射到进程的虚拟地址空间中的一段区域。这样文件和数据就在进程的虚拟地址空间中有了一个对应的位置。

    总结:文件映射区其实就是直接将磁盘中的文件映射到了虚拟地址上,而没有走页表然后映射物理内存的过程,省去了一个环节,这样更加高效。当进程对映射区进行读写操作时,实际上是对磁盘上的文件进行操作

    当进程中有对文件的操作,比如读写文件时,就需要通过mmap创建一个文件映射区了。

    匿名映射区

    匿名映射区顾名思义就是,没有固定的载体,类似磁盘这样。

    匿名映射区就是在虚拟地址空间中通过mmap创建一个映射区,但不与任何文件关联,用于在进程之间 共享数据或为进程提供临时的共享内存空间

    很多地方对于匿名映射区的定义都是这样的,前半句话没问题“匿名映射区就是在虚拟地址空间中通过mmap创建一个映射区”,后半句话“用于在进程之间 共享数据或为进程提供临时的共享内存空间”是有问题的,不太严谨,很容易对小白造成误会(虽然我也是小白),为什么说后半句话是有点问题的呢,主要一下几点原因

    • 独立的进程间资源都是独立的,私有的。不管匿名映射区还是文件映射区,还是代码段、数据段,在独立进程间是不可能共享的。
    • 而对于父子进程来说,子进程是继承父进程的内存资源的,也就是只有父子进程的情况下,匿名映射区才可以在父子进程间共享数据和进程间通信的

    举一个具体的例子,假设有一个父进程和多个子进程,父进程需要将一些数据共享给子进程,并且子进程也可以将处理结果返回给父进程。这时,匿名映射就可以派上用场。

    #include <stdio.h>
    #include <sys/mman.h>
    #include <unistd.h>
    #include <sys/wait.h>
    
    #define DATA_SIZE 1024
    
    int main() {
        int *shared_data;
        pid_t pid;
    
        // 创建匿名映射区,指定大小为DATA_SIZE字节
        shared_data = mmap(NULL, DATA_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
        if (shared_data == MAP_FAILED) {
            perror("mmap");
            return 1;
        }
    
        // 在父进程中设置共享数据
        *shared_data = 42;
    
        pid = fork();
        if (pid == 0) {
            // 在子进程中访问共享数据
            printf("Child process: Shared data = %d\n", *shared_data);
            *shared_data = 100; // 修改共享数据
            printf("Child process: New shared data = %d\n", *shared_data);
        } else if (pid > 0) {
            // 等待子进程结束
            wait(NULL);
    
            // 在父进程中访问共享数据,看到子进程对共享数据的修改
            printf("Parent process: Shared data = %d\n", *shared_data);
        } else {
            perror("fork");
            return 1;
        }
    
        // 解除映射区
        munmap(shared_data, DATA_SIZE);
    
        return 0;
    }
    

    在这个例子中,我们使用mmap函数创建了一个匿名映射区,大小为DATA_SIZE字节。父进程设置了共享数据为42,然后创建了一个子进程。在子进程中,它读取共享数据并修改为100。父进程等待子进程结束后,再次访问共享数据,可以看到子进程修改后的结果。

    这样,父进程子进程就通过匿名映射实现了共享数据的通信。匿名映射提供了一种轻量级的、在父子进程间共享数据的方法,而不需要涉及具体的文件操作。

    动态链接库

    为什么这里要介绍下动态链接库呢?因为动态链接库也是属于磁盘文件,而程序运行时通常要加载很多的动态链接库,而动态链接库正式使用文件映射的方式加载到虚拟空间中的文件映射段的。

    什么是动态链接库呢?

    在程序代码中,动态链接库(Dynamic Link Library,简称 DLL)是一种在运行时加载的共享库文件。它是一种常见的软件开发概念,主要用于在不同的应用程序之间共享代码和资源,从而减少重复的代码,节省内存空间,并提高应用程序的执行效率。

    下面介绍一下静态链接库和动态链接库的一些区别:
    静态链接库:

    • 链接时间:在编译时将库的代码和数据复制到编译好的二进制文件中。
    • 加载方式:当程序启动时,操作系统将整个静态链接库的代码和数据加载到进程的虚拟内存空间中。
    • 虚拟内存占用:由于静态链接库的也有自己的代码和数据,所以静态链接库会将这些资源加载到虚拟内存中的代码段和数据段中。

    动态链接库:

    • 链接时间:在编译时并不将库的代码和数据复制到二进制文件中
    • 加载方式:当程序启动时,并不将整个动态链接库的代码和数据加载到内存中。而是在需要使用库中函数或资源时,将磁盘中的数据加载到内存中。
    • 虚拟内存占用:由于动态链接库的代码和数据在运行时才加载到内存,会通过文件映射的方式加载到虚拟内存的文件映射段中,使用该库的进程可以共享同一份库的内存副本(通过匿名映射),节省了内存资源。

    不管是动态链接库还是静态链接库,都有属于自己的代码段,数据段,只不过静态链接库是直接对应到虚拟内存的代码段和数据段上,而动态链接库则是全部映射到虚拟内存的文件映射段。

    说了这么多,可能你还不知道具体的动态链接库到底是什么?

    • Linux和Unix系统:在大多数 Linux 和 Unix 系统上,动态链接库的后缀是 ".so"。

    • Windows系统:在 Windows 系统中,动态链接库的后缀通常是 ".dll"(Dynamic Link Library的缩写)。

    • macOS系统:在 macOS 系统中,动态链接库的后缀通常是 ".dylib"(Dynamic Library的缩写)。

    通过下面这种图,你就大概清楚了,原来这就是动态链接库啊,恍然大悟。通过引入第三方中间件的库时都需要加载这些动态链接库。



    那怎么查看这些动态链接库的具体内容呢?

    在Linux和Unix系统上:

    • 使用nm命令:nm命令可以列出目标文件或共享库中的符号表(Symbol Table),包括函数名、变量名等信息。
    nm -C your_library.so
    

    -C选项将会使用人类可读的函数名进行显示,这样更易于理解。

    • 使用objdump命令:objdump命令用于显示目标文件的信息,包括各个节(section)的内容。
    objdump -T your_library.so
    

    栈段

    最后我们在程序运行的时候总该要调用各种函数吧,那么调用函数过程中使用到的局部变量函数参数也需要一块内存区域来保存。这一块区域在虚拟内存空间中叫做栈。

    栈地址是由高地址向低地址扩展的,栈和文件映射和匿名映射区的地址不是连续的,中间有一块待分配区域,主要用于栈的扩展。

    父子进程与独立进程的区别

    上面提到了很多次父子进程,那么到底什么是父子进程呢?和独立的进程又有什么区别呢?

    父子进程:
    父子进程是指一个进程创建了另一个新的进程,这个新进程就成为了原始进程的子进程。在这种关系中,子进程继承了父进程的某些属性和资源,如文件描述符、内存映射等。父进程通常是一个执行者,它会创建并控制子进程执行某些特定的任务。子进程可以独立地执行,但是在创建时会复制父进程的状态,包括内存和文件描述符等信息

    独立进程:
    独立进程是指两个或多个进程之间没有父子关系,它们彼此独立地运行,不共享资源或状态。每个独立进程都有自己的地址空间和系统资源,它们之间不会相互影响或通信。

    在刚介绍进程时有句话为进程间的资源是独立的,不共享的,相信很多人也是听过这句话的,但是这句话只适用于独立进程,而不适用于父子进程,具体原因看我下面介绍。

    独立进程的内存模型

    独立进程间是没有直接关联的,每个进程都会申请自己所属的虚拟地址和页表信息。

    每个进程的虚拟地址都存在下面这些信息,可以发现,其实每个进程需要的资源还是蛮多的,内存资源、文件资源等。

    父子进程的内存模型

    为什么进程间的资源是独立的,不共享的这句话不适用父子进程呢?继续往下看。

    上文中对父子进程的介绍中有俩个关键字:继承复制,很重要,从以下几个方面介绍。

    创建子进程时:

    通常一个新的子进程通过fork()系统调用被创建时,它会成为父进程的副本,会继承父进程的所有资源,包括父进程的代码、数据、堆、栈等内容
    也就意味着刚创建时的子进程和父进程的虚拟地址空间是一样,是继承的父进程的虚拟地址。
    可以理解为子进程此时并没有申请虚拟地址也没有申请物理内存地址,完全就是用的父进程的虚拟地址。

    子进程读数据时:

    要知道不管是父子进程还是独立进程,进程中的虚拟地址中的所有段都是私有的,都是只读的。
    子进程需要读数据时,不管读的是代码段数据段BSS段堆段文件映射和匿名映射段还是栈段,都是直接读取父进程对应的段的,而不会将数据复制到子进程的虚拟内存中。
    这样做的好处是:不需要将数据复制到所有的子进程中,节省了大量内存

    子进程写数据时:

    • 代码段通常是存储的二进制文件,这些数据通常是只读的,是不可修改的,所以子进程不可写代码段数据
    • BSS段通常是存储的是未初始化的全局变量和静态变量,这些变量的值都为零值。这些数据通常是只读的,也是不可修改的,所以子进程不可写BSS段数据
    • 数据段、堆栈段和文件映射区对于子进程来说都是可修改比如子进程想要修改堆段中的值时,不是直接修改父进程堆段的数据而是子进程复制一份父进程堆段的数据到自己所属的虚拟内存中的堆段,让这个子进程独立拥有一份可写的堆段,然后子进程就可以修改自己所属的堆段数据。这个过程变相的体现了进程间资源是独立,子进程你读可以,但是写的话需要写你自己的数据。
    • 匿名映射区在前面的介绍中有句话:用于在进程之间 共享数据或为进程提供临时的共享内存空间。更准确的是 用于在父子进程之间共享数据或为进程提供临时的共享内存空间。
      首先要知道父进程的的代码段、BSS段、数据段、堆栈段和文件映射区对于子进程都是只读的,匿名映射区对于子进程却是在可读写的。
      在匿名映射区中,数据是可供多个进程读写的,但同一个数据只会在多个进程中的一个进程中存在。当一个进程创建匿名映射区后,该映射区会在这个进程的虚拟地址空间中存在。
      如果多个进程都需要访问同一个共享数据,通常的做法是在父进程中创建匿名映射区,子进程共享父进程的匿名映射区。
      匿名映射区适用于父子进程之间的共享数据、临时共享内存、进程间通信等场景。不过需要注意的是,虽然多个进程可以访问同一个匿名映射区,但这些进程之间需要进行进程间同步,以避免数据的不一致性或竞态条件。

    子进程在读数据时直接读取父进程的数据,子进程只有在写数据时,才会复制父进程的数据到本进程内的虚拟空间内然后写数据,不直接写父进程的虚拟空间内的数据,写数据的这个过程叫做写时复制机制顾名思义就是当一个进程修改数据时,操作系统会复制一份新的数据给该进程,从而保持各个进程之间的独立性。

    写时复制机制很好的节省内存资源并保持数据的独立性。

    线程

    在早期的操作系统中都是以进程作为独立运行的基本单位,由于进程都需要很多资源,如果进程非常多则非常消耗有限的系统系统,后来,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。

    很多人都听过线程是共享进程资源的,其实这句话也不全是对的,应该是共享进程的部分资源,而对于寄存器来说,每个线程都是独立拥有的,这样可以保证每个线程有自己独立的执行分支。

    对于线程和进程,我们可以这么理解:

    • 当进程只有一个线程时,可以认为进程就等于线程;
    • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;

    线程与进程的比较如下:

    进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;

    • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
    • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
    • 线程能减少并发执行的时间和空间开销;

    对于,线程相比进程能减少开销,体现在:

    • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
    • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
    • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
    • 进程间的通信通常可以使用通道、消息队列、共享内存、信号量、信号、socket方式来通信,不然哪种方式都是在内核空间进行通信的,因为所有进程的内核空间是共享的
      由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,可以使用全局变量来进行通信,这就使得线程之间的数据交互效率更高了;

    所以,不管是时间效率,还是空间效率线程比进程都要高。

    内存模型

    进程下的所有线程都共享进程的内存资源和文件资源,除了寄存器

    进程的虚拟空间的中的所有段对于它的所有线程来说都是可读的,这个和父子进程是一样的。

    • 进程中的代码段一般是不可能修改的,线程中也不会修改该段,只允许读所以共享读取进程的数据段地址即可。

    • 进程中的BSS段是存储的未初始化的全局变量和静态变量,存储的都是零值,该内容一般也是不可能修改的,线程中也不会修改该段,只允许读所以共享读取进程的BSS段地址即可。

    • 进程中的数据段、堆段、文件映射和匿名映射段是允许读写的,如果想要读取该段数据,则直接共享读取进程的对应段地址即可;如果线程想要修改时,则是直接在进程对应的段地址上修改,而不需要像父子进程那样写时复制。

    • 而对于进程中的栈段就是私有的了,且是只读的 ,如果线程想要读取栈数据,则直接共享读取进程的栈段地址即可;而如果想要修改栈数据,则必须使用写时复制机制将进程栈中的数据复制到本线程内,在进行修改。

    看完上述的线程的介绍我相信你应该大致了解一二了,其实线程和父子进程有点类似,对比如下

    • 进程中的所有段内容对于子进程或者线程来说都是可读的。
    • 进程中的数据段和BSS段不管对于子进程还是线程来说都是只读的。
    • 子进程如果想要修改父进程中的数据段、堆栈段和文件映射和匿名映射段的数据,则必须使用写时复制机制来实现。
      而线程可堆进程的数据段、堆段和文件映射和匿名映射段的数据可直接修改,只有栈数据修改时才使用写时复制机制实现

    对于线程,进程中的数据段、堆栈段和文件映射和匿名映射段的数据是共享的,很容易造成资源竞争和冲突,统称多线程冲突,通常的解决方式有以下俩种

    • 锁:可以解决资源互斥的问题
    • 信号量:可以解决资源互斥和同步的问题。

    一个进程最多能创建多少个线程

    上面介绍可知,线程是共享进程的内存资源,而线程拥有私有的栈和寄存器。

    那么有个问题就是一个进程最多能创建多少个线程呢?

    我们知道线程是不存在单独的虚拟内存和页表的,而是共享使用进程的虚拟内存和页表的。这个问题其实转变一下就是在进程的虚拟内存中最多能存储多少个栈呢

    我们可以执行 ulimit -a 这条命令,查看进程创建线程时默认分配的栈空间大小,比如我这台服务器默认分配给线程的栈空间大小为 8M。


    在前面我们知道,在 32 位 Linux 系统里,一个进程的虚拟空间是 4G,内核分走了1G,留给用户用的只有 3G

    那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是我们可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。

    64 位系统意味着用户空间的虚拟内存最大值是 128T,这个数值是很大的,如果按创建一个线程需占用 10M 栈空间的情况来算,那么理论上可以创建 128T/10M 个线程,也就是 1000多万个线程,有点魔幻!

    所以按 64 位系统的虚拟内存大小,理论上可以创建无数个线程。

    读到这里,可能有个疑问?既然线程创建的栈是存储在进程的虚拟空间中的,那么父子进程中,子进程通过写时复制机制创建的内存资源也是存储在父进程的虚拟内存空间吗?

    答案当然是不会存储在父进程的虚拟空间当中了,因为子进程本质也是一个进程,每个进程都会创建私有的虚拟内存和页表

    也就是子进程发生写时复制时,子进程会创建属于自己的虚拟空间和页表来存储写时复制创建资源,自然而然就没有父进程最多创建多少个进程这样的问题了。

    协程

    在继线程之后,又出来一种调度单位:协程,在go中广泛应用,可能很多小伙伴也知道,协程是相对于线程来说,是一种更加轻量,更加适合并发的调度单位。

    那为什么说协程是相对于线程来说,是一种更加轻量,更加适合并发的调度单位呢?我们可以先从他的内存模型入手试着看看。

    内存模型

    告诉你一个秘密,协程的内存模型和线程的一模一样进程下的所有协程都共享进程的内存资源和文件资源,除了栈和寄存器。

    协程中对于虚拟内存中的各个段的读写和线程也是完全一致,完全可以看上节中线程的内存模型。

    可以发现协程的父级也是进程而不是线程,这可能是很多小伙伴的误区。

    为什么协程相对于线程更加轻量

    既然协程的内存模型和线程的一模一样,那么为什么说是协程是相对于线程来说,是一种更加轻量,更加适合并发的调度单位呢?主要有以下几种原因

    • 栈空间大小可调:传统的线程在创建时需要分配固定大小的栈空间,通常是数MB,这使得每个线程的内存消耗相对较大。而协程的栈空间大小可以根据需要动态地增长和收缩,通常只需要几KB。这意味着可以同时创建大量的协程而不会造成内存消耗过大的问题。

    • 调度和切换成本低:协程的调度和切换由 Go 运行时(runtime)自行管理,这样的调度是基于用户态的,比起操作系统线程上下文切换的开销要小得多。线程切换需要保存和恢复完整的 CPU 寄存器状态以及内核数据结构,而协程的切换只需要保存少量的上下文信息,如栈指针和程序计数器。这使得协程的切换速度更快,有助于更高效地处理并发任务。

    • 合作式调度:传统的线程通常采用抢占式调度(进程和线程的调度方式一般为时间片方式),即操作系统会强制性地将一个线程从 CPU 中剥夺执行权,转而执行另一个线程。而协程采用的是·合作式调度·,即协程自己决定在何时让出 CPU 执行权。这样的调度方式避免了线程之间频繁的上下文切换,也减少了资源竞争的可能性,提高了并发性能。

    • 并发原语和通信机制:Go 语言提供了丰富的原生并发原语和通信机制,如 channels(通道),Mutex(互斥锁),WaitGroup(等待组)等。这些原语的设计和实现都与协程的特性紧密结合,使得并发编程更加简单和安全。

    综合上述因素,协程在处理大量并发任务时比传统的线程更加轻量级、高效。它们能够更好地利用 CPU 资源,减少上下文切换开销,并提供更简单且安全的并发编程模型,使得并发应用的开发和维护更加容易。

    相关文章

      网友评论

          本文标题:进程、线程和协程的深度剖析

          本文链接:https://www.haomeiwen.com/subject/sqwmrdtx.html