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