目录
一、进程
1.1、进程状态
我们编写代码只是在一个存储在硬盘的静态文件,通过编译之后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存之中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称作进程。
一个进程的活动期间至少具备三种基本状态:运行状态、就绪状态、阻塞状态
- 运行状态(Running):该时刻进程占用 CPU
- 就绪状态(Ready):可运行,由于其他进程处于运行态而暂时停止运行
- 阻塞状态(Blocked):该进程正在等待某一事件发生而暂时停止运行,此时,即使给它 CPU 控制权,它也无法进行
此外还有两种基本状态:
- 创建状态(new):进程正在被创建时的状态
- 结束状态(Exit):进程正在从系统中消失时的状态
与是一个完整的进程状态变迁如下:
进程状态变迁:
- NULL - > 创建状态:一个新进程被创建时的第一个状态
- 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的
- 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配 CPU 正式运行该进程
- 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统做结束状态处理
- 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统就会把该进程变为就绪态,接着从就绪态选中另外一个进程运行
- 运行状态 -> 阻塞状态:当进程请求某个时间且必须等待时,例如请求 IO 事件
- 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态
在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换到物理内存,进程没有占用实际的物理内存空间的情况,这个状态就是【挂起状态】。
另外挂起状态可分为两种:
- 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现
- 就绪挂起状态:进程在外村(硬盘),但只要进入内存,立刻运行
1.2、进程的控制结构
PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。
PCB 包含什么信息呢?
①进程描述信息:
- 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符
- 用户标识符:进程归属的用户,用户标识符主要为共享和保护
②进程控制和管理信息:
- 进程当前状态,如 new,ready,running,waiting 或 blocked 等
- 进程优先级:进程抢占 CPU 时的优先级
③资源分配清单
有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 IO 设备信息
④CPU 相关信息
CPU 中各个寄存器的值, 当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便重新执行时,能从断点处继续执行
PCB 是如何组织的呢?
通常是通过链表的方式进行组织,把具有相同状态的进程链在一块,组成各种队列,如:
将所有处于就绪状态的进程链在一块,称为【就绪队列】
把所有因等待某事件而处于等待状态的进程链在一起就组成了各种【阻塞队列】
另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个事件,只能运行一个程序
1.3、进程的控制
①创建进程
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源
创建进程的过程如下:
- 申请一个空白的 PCB ,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识符
- 为该进程分配运行时所需的资源,比如内存资源
- 将 PCB 插入到就绪队列,等待被调度运行
②终止进程
进程可以有三种终止方式:正常结束,异常结束以及外界干预(信号 kill 掉)
当子进程被终止时,在父进程处继承的资源应当归还给父进程。而当父进程被终止时,该父进程的子进程就变成孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作
终止进程过程如下:
- 查找需要终止进程的 PCB
- 如果出于执行状态,则立刻终止该进程的执行,然后将 CPU 资源分配给其他进程
- 如果其还有子进程,则应将该进程的子进程交由 1 号进程接管
- 将该进程所拥有的全部资源都归还给操作系统
- 将其从 PCB 所在队列中删除
③阻塞进程
当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。过程如下:
- 找到将要被阻塞进程标识号对应的 PCB
- 如果改进程为运行状态,则保护现场,将其状态转为阻塞状态,停止运行
- 将该 PCB 插入到阻塞队列中
④唤醒进程
进程由【运行】转变为【阻塞】状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。唤醒过程如下:
- 在该事件的阻塞队列中找到相应进程的 PCB
- 将其从阻塞队列中移出,并置状态为就绪状态
- 把该 PCB 插入到就绪队列中,等待程序调度
1.4、进程的上下文切换
一个进程切换到另一个进程运行,称为进程的上下文切换,进程是由内核管理和调度的,所以进程的切换只能发生在内核态,所以进程的上下文切换不仅包含了虚拟内存,栈,全局变量等用户空间的资源,还包括了内核堆栈,寄存器等内核空间的资源。举个🌰:
发生进程切换上下文的场景:
- 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
二、线程
2.1.线程是什么
线程是进程当中的一条执行流程。
同一个线程内多个线程之间可以共享代码段,数据段,打开的文件等资源,但每个线程都有各自一套独立的寄存器和栈,这样就可以确保线程的控制流是相对独立的。
优点:
- 一个进程可以同时存在多个线程
- 各个线程之间可以并发执行
- 各个线程之间可以共享地址空间和文件等资源
缺点:
- 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃
2.2、线程与进程的比较
线程与进程的比较如下:
- 进程是资源分配的单位,线程是 CPU 调度的单位
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器与栈
- 线程同样具有就绪,阻塞,执行三种基本状态,同样具有状态之间的转换关系
- 线程能减少并发执行的时间和空间开销
线程相比进程能减少开销体现在:
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息,文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们
- 线程终止时间比进程快,因为线程释放的资源相比进程少很多
- 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间,这意味着同一个进程的线程都具有同一个页表,那么在切换时不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的
- 由于同一进程的各线程之间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了
2.3、线程的上下文切换
线程与进程最大的区别是:线程是调度的基本单位,而进程则是资源拥有的基本单位
所以操作系统的任务调度,实际上的调度对象是线程,而进程只是给了线程提供了虚拟内存,全局变量等资源。所以我们可以这样理解:
- 当进程只有一个线程时,可以认为进程就等于线程
- 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换的时候是不需要更改的
另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也需要保存
线程上下文切换的是什么?
- 当两个线程不是属于同一个进程,则切换的过程就像进程上下文切换一样
- 当两个线程属于同一个进程时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不懂,只需要切换线程的私有数据,寄存器这些不共享的数据
2.4、线程的实现
主要有三种线程的实现方式:
- 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理
- 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程
- 轻量级线程(LightWeight Process):在内核中来支持用户线程
此时我们需要考虑用户线程和内核线程的对应关系:
- 多对一的关系,也就是多个用户进程对应同一个内核线程
- 一对一的关系,也就是一个用户线程对应一个内核线程
- 多对多的关系,也就是多个用户线程对应到的多个内核线程
用户线程:
用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block,TCB)也是在库里面实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB
所以,用户进程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等
用户线程优点:
- 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息,TCB 由用户级线程库来完成的,无需用户态与内核态的切换,所以速度特别快
- 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快
缺点:
- 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了
- 当一个线程开始运行后,除非它主动的交出 CPU 的使用权,否则它在的进程当中的其他进程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统有,但是用户线程不是由操作系统管理的
- 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片少,执行会比较慢
内核线程:
内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统中的,这样线程的创建、终止和管理都是由操作系统负责
优点:
- 在一个进程中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行
- 分配给线程,多线程的进程获得更多的 CPU 运行时间
缺点:
- 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB
- 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大
2.5、轻量级线程
轻量级线程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每一个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。
在大多数的系统下,LWP 与普通进程的却别也就在于它有一个最小的执行上下文和调度程序所需的统计信息。
在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系也是三种:一对一,多对一,多对多。
1 : 1 模式:
一个线程对应到一个 LWP 再对应到一个内核线程
- 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP
- 缺点:每一个用户线程,就产生一个内核线程,创建线程开销较大
N : 1 模式:
多个用户线程对应一个 LWP 再对应一个内核线程
- 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换效率高
- 缺点:一个用户进程阻塞了,则整个进程都会被阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的
M : N 模式:
根据前面两种模式混搭在一起,就形成了 M : N 模式
优点:综合了前面两种的优点,大部分的线程上下文切换发生在用户空间,且多个线程又可以充分利用 CPU 资源。
三、进程间的通信方式
每个进程的用户空间都是独立的,一般而言是不能相互访问的,但内核空间是每个进程都共享的,所以进程之间通信必须通过内核
3.1、管道
管道分为匿名管道和有名管道,其中匿名管道适用于具有亲缘关系进程之间的通信。
管道这种通信方式效率低,不适合进程间频繁地交换数据。当然它的好处是简单,同时我们很容易得知管道里的数据被另一个进程读取。
匿名管道创建通过:
cpp
int pipe(int fd[2])
表示创建一个匿名管道,并返回两个描述符,一个是管道的读取端描述符 fd[0],另一个是写入端描述符 fd[1] 。注意,这个匿名管道是特殊的文件,只存在于内存中,不存在于文件系统中。
其实,所谓的管道就是内核中的一段缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且受大小限制。
我们在使用 fork 创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个【fd[0],fd[1]】,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信。
管道只能一端读,另一端写,因为父子进程都可以同时写与读,这样很容易就造成混乱,通常的做法就是:父进程关闭读取的 fd[0] ,子进程关闭写入的 fd[1] ,所以说需要双向通信,就应该创建两个管道。
对于命名管道,它可以在不相关的进程间也能相互通信。因为命名管道,提前创建了一个类型为管道的设备文件,进程只要使用这个设备文件,就可以相互通信。
不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则。
3.2、消息队列
前面写到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据
对于这个问题,消息队列的通信模式就可以解决
消息队列是保存在内核中的消息链表,在发送数据时,会被拆分成一个个独立的单元,也就是消息体,消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁
缺点:
- 消息队列不适合比较大数据的传输,因为在内核中每个消息体都会有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB ,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
- 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
3.3、共享内存
消息队列的读取和写入的过程,都会有发生用户与内核态之间的消息拷贝过程。那共享内存的方式就很好的解决了这个问题。
共享内存机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中,这样两个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,大大提高了进程间通信的速度。
3.4、信号量
共享内存通信机制带来了一个新问题,那就是如果多个进程同时修改一个共享内存,很有可能就冲突了。例如两个进程同时写一个地址,那先写的那个地址会发现内容被别人覆盖了
为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。信号量就正好提供了这一保护机制。
信号量其实就是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量代表资源的数量,控制信号量的方式有两种原子操作:
- P 操作:这个操作会把信号量减 1 ,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待。相减后如果信号量 >= 0,则表明资源还可使用,进程可正常继续执行。
- V 操作:这个操作会把信号量加 1 ,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行,相加后如果信号量 > 0 ,则表明当前没有阻塞中的进程。
P 操作是用在进入共享资源前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
当信号初始化为 1 ,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
当信号量初始化为 0 ,就代表着是同步信号量,它可以保证进程之间的同步。
3.5、信号
上面说的进程间的通信,都是常规状态下的工作状态。对于异常情况下的工作模式,就需要使用【信号】的方式来通知进程。
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面的几种用户进程对信号的处理方式。
执行默认操作。Linux 对每种信号都规定了默认操作,例如 SIGINT 信号,就是终止该进程的意思
捕捉信号。可以为信号定义一个信号处理函数。当信号发生时,我们就可以执行相应的信号处理函数
忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP ,它们用在任何时刻中断或结束某一进程。
3.6、Socket
上述的方式都是在同一主机进行进程进程间的通信,那么想要跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。实际上它不仅可以跨网络与不同主机的进程间通信,还可以在同主机进程间通信。
进行本地进程通信时,在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是他们之间的最大区别。
四、多线程冲突
- 竞争条件:当多线程相互竞争操作共享变量时,在执行过程中发生了上下文切换。
- 不确定性:每次运行都可能得到不同的结果
- 临界区:访问共享资源的代码片段,一定不能给多线程同时使用
- 互斥:保证一个线程在临界区执行时,其他线程应该被阻止进入临界区
- 同步:就是并发进程/线程在一些关键节点上可能需要互相等待与互通消息,这种相互制约的等待与互通消息称为进程/线程同步。
哲学家问题:
五、如何避免死锁
造成死锁的条件
- 互斥条件:多个线程不能同时使用同一个资源
- 持有并等待条件:线程在等待某一资源的时候不会释放已持有的资源
- 不可剥夺条件:在自己使用完之前不能被其他线程获取
- 环路等待条件:两个线程获取资源的顺序构成了环形链
避免死锁的方法是破坏上述其中的任一条件即可
六、锁
6.1、互斥锁与自旋锁
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程
- 自旋锁加锁失败后,线程会忙等待,直到它拿锁
互斥锁是一种【独占锁】,比如线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU ,自然线程 B 加锁的代码就被阻塞。对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为【睡眠】状态,等到锁被释放后,内核会在合适的实际唤醒线程,当这个线程获取到锁后,于是就可以继续执行。
互斥锁在加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本,而这个开销的成本就是会有两次线程上下文切换的成本:
- 当线程加锁失败时,内核会把线程的状态从【运行】状态设置为【睡眠】状态,然后把 CPU 切换给其他线程运行
- 接着,当锁被释放时,之前【睡眠】状态的线程就会变为【就绪】状态,然后内核会在合适的时间,把 CPU 切换给该线程运行
线程的上下文切换是什么?当两个线程属于同一个线程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据,寄存器这些等不共享的数据。
如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该用自旋锁,否则使用互斥锁。
自旋锁是一种比较简单的锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意的是,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单核 CPU 上无法使用,因为一个自旋锁的线程永远不会放弃 CPU。
当加锁失败时,互斥锁用【线程切换】来应对,自旋锁则是【忙等待】来应对。
6.2、读写锁
读写锁适用于能明确区分读操作和写操作的场景
读写锁的工作原理:
- 当【写锁】没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为【读锁】是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
- 但是,一旦【写锁】被线程持有后,读线程的获取读锁的操作会被阻塞。而且其他写线程的获取写锁的操作也会被阻塞。
所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。
知道了读写锁的工作原理后,我们可以发现,读写锁在都多写少的场景都能发挥出优势。
根据实现的不同,读写锁还分为【读优先锁】和【写优先锁】:
- 读优先锁:它期望的是,读锁能够被更多的线程持有,以便提高读线程的并发性,它的工作方式是,当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功的获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获得写锁。
- 写优先锁:是优先服务于写线程,其工作方式是,当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将会被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获得写锁。
公平读写锁比较简单的一张方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现【饥饿】的现象。
6.3、乐观锁与悲观锁
上述的互斥锁、自旋锁、读写锁都是属于悲观锁。
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前要先上锁。
相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁比较乐观,它假定冲突的概率比较低,它的工作方式是:先修改完共享资源,再验证这一段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。可见乐观锁是真的很乐观,不管三七二十一,想改了资源再说。另外,乐观锁全程并没有加锁,所以也叫它无锁编程。
6.4、一个进程最多可以创建多少个线程呢?
这个问题和两个东西有关:
进程的虚拟内存空间上限,因为创建一个线程操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会越占用的越多。
系统参数限制,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。
在 32 位 Linux 系统里,一个进程的虚拟空间是 4 G ,内核分走了 1 G,留给用户的只有 3 G。
假设创建一个线程需要占用 10M 的虚拟内存,总共只有 3 G 虚拟内存可以使用,于是我们可以算出,最多可以创建差不多 300 个左右的线程。如果想使得进程创建上千个线程,那么我们可以调整创建线程时分配的栈空间大小,比如调整为 512k :【ulimit -s 512】
在 64 位系统里,意味着用户空间的虚拟内存最大值是 128 T,这个数值很大,如果创建一个线程需占用 10M 栈空间的情况来算,那么理论上可以创建 128T / 10M 个线程,也就是 1000多W 个线程,但是实际上创建不了这么多,除了虚拟内存的限制,还有系统的限制:
- /proc/sys/kernel/threads-max,表示系统支持的最大线程数,默认是 14553
- /proc/sys/kernel/pid_max,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或者线程就会创建失败,默认是 32768
- /proc/sys/vm/max_map_count,表示限制一个进程可以拥有的 VMA(虚拟内存区域)的数量,如果这个值很小,也会导致创建线程的失败,默认值是 65530