进程是资源分配的最小单位,是最小的资源管理单元。
直白地讲,进程就是应用程序的启动实例。比如我们运行一个游戏,打开一个软件,就是开启了一个进程。
每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
当程序需要运行时,操作系统将代码和所有静态数据记载到内存和进程的地址空间(每个进程都拥有唯一的地址空间,见下图所示)中,通过创建和初始化栈(局部变量,函数参数和返回地址)、分配堆内存以及与IO相关的任务,当前期准备工作完成,启动程序,OS将CPU的控制权转移到新创建的进程,进程开始运行。
PCB:
操作系统对进程的控制和管理通过PCB(Processing Control Block),PCB通常是系统内存占用区中的一个连续存区,它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息(进程标识号,进程状态,进程优先级,文件系统指针以及各个寄存器的内容等),进程的PCB是系统感知进程的唯一实体。
一个进程至少具有5种基本状态:初始态、执行状态、等待(阻塞)状态、就绪状态、终止状态。
一、同步机制的主要任务
进程同步机制的主要任务是对多个相关的进程在执行次序上进行协调,使并发执行的诸多进程之间能够按照一定的规则共享系统资源,并能很好的相互合作,从而使程序之间的执行具有可再现性。
一、进程间的两种制约关系:
- 间接相互制约(互斥):因为进程在并发执行的时候共享临界资源而形成的相互制约的关系,需要对临界资源互斥地访问;
- 直接制约关系(同步):多个进程之间为完成同一任务而相互合作而形成的制约关系。
二、 临界资源:
指同一时刻只允许一个进程可以该问的资源称之为临界资源,诸进程之间采取互斥方式,实现对临界资源的共享。
三、临界区
进程中访问临界资源的那段代码。
每个进程在进入临界区之前,应先对欲访问的临界资源的“大门”状态进行检测,如果“大门”敞开,进程便可进入临界区,并将临界区的“大门”关上;否则就表示有进程在临界区内,当前进程无法进入临界区。
二、同步机制应遵循的规则
“忙等 ”和 “死等” 都是没能进入临界区,它们的区别如下:
- 死等: 对行死等的进程来说,这个进程可能是处于阻塞状态,等着别的进程将其唤醒(signal 原语),但是如果唤醒原语一直无法执行,对于阻塞的进程来说,就是一直处于死等的状态,是无法获得处理机的。
- 忙等:忙等状态比较容易理解,处于忙等状态的进程是一直占有处理机去不断的判断临界区是否可以进入,在此期间,进程一直在运行,这就是忙等状态。有一点需要注意的是,忙等是非常可怕的,因为处于忙等的进程会一直霸占处理机的,相当于陷入死循环了。 忙等的状态在单CPU 系统中是无法被打破的,只能系统重启解决。
进程通信( InterProcess Communication,IPC)就是指进程之间的信息交换。为了保证安全,每个进程的用户地址空间都是独立的,一般而言一个进程不能直接访问另一个进程的地址空间,不过内核空间是每个进程都共享的,所以 「进程之间想要进行信息交换就必须通过内核」
进程间通信有六大机制,如下:
管道的本质就是内核在内存中开辟了一个缓冲区,这个缓冲区与管道文件相关联,对管道文件的操作,被内核转换成对这块缓冲区的操作。
$ command1 | command2
$ mkfifo mypipe
管道这种进程通信方式虽然使用简单,但是效率比较低,不适合进程间频繁地交换数据,并且管道只能传输无格式的字节流。为此,消息传递机制(Linux 中称消息队列)应用而生。
消息队列的本质就是存放在内存中的消息的链表,而消息本质上是用户自定义的数据结构。 比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程在需要的时候自行去消息队列中读取数据就可以了。同样的,B 进程要给 A 进程发送消息也是如此。
如果进程从消息队列中读取了某个消息,这个消息就会被从消息队列中删除。对比一下管道机制:
消息队列对于交换较少数量的数据很有用,因为无需避免冲突。但是,由于用户进程写入数据到内存中的消息队列时,会发生从用户态拷贝数据到内核态的过程;同样的,另一个用户进程读取内存中的消息数据时,会发生从内核态拷贝数据到用户态的过程。因此,如果数据量较大,使用消息队列就会造成频繁的系统调用,也就是需要消耗更多的时间以便内核介入。
为了避免像消息队列那样频繁的拷贝消息、进行系统调用,共享内存机制出现了。
共享内存就是允许不相干的进程将同一段物理内存链接到它们各自的地址空间中,使得这些进程可以访问同一个物理内存,这个物理内存就称为共享内存。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。
共享内存的原理:首先,每个进程都有属于自己的进程控制块(PCB)和逻辑地址空间(Addr Space),并且都有一个与之对应的页表,负责将进程的逻辑地址(虚拟地址)与物理地址进行映射,通过内存管理单元(MMU)进行管理。两个不同进程的逻辑地址通过页表映射到物理空间的同一区域,它们所共同指向的这块区域就是共享内存。
不同于消息队列频繁的系统调用,对于共享内存机制来说,仅在建立共享内存区域时需要系统调用,一旦建立共享内存,所有的访问都可作为常规内存访问,无需借助内核。这样,数据就不需要在进程之间切换CPU状态来回拷贝,所以这是最快的一种进程通信方式。
对具有多 CPU 系统的最新研究表明,在这类系统上,消息传递的性能其实是要优于共享内存的,因为消息队列无需避免冲突,而共享内存机制可能会发生冲突 。也就是说如果多个进程同时修改同一个共享内存,先来的那个进程写的内容就会被后来的覆盖。
并且,在多道批处理系统中,多个进程是可以并发执行的,但由于系统的资源有限,进程的执行不是一贯到底的, 而是走走停停,以不可预知的速度向前推进(异步性)。但有时候我们又希望多个进程能密切合作,按照某个特定的顺序依次执行,以实现一个共同的任务。
举个例子,如果有 A、B 两个进程分别负责读和写数据的操作,这两个线程是相互合作、相互依赖的。那么写数据应该发生在读数据之前。而实际上,由于异步性的存在,可能会发生先读后写的情况,而此时由于缓冲区还没有被写入数据,读进程 A 没有数据可读,因此读进程A被阻塞。
因此,为了解决上述这两个问题,保证共享内存在任何时刻只有一个进程在访问(互斥),并且使得进程们能够按照某个特定顺序访问共享内存(同步),我们就可以使用进程的同步与互斥机制,常见的比如信号量与 PV 操作。
进程的同步与互斥其实是一种对进程通信的保护机制,并不是用来传输进程之间真正通信的内容的,但是由于它们会传输信号量,所以也被纳入进程通信的范畴,称为低级通信.
信号量其实就是一个变量 ,我们可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为 1 的信号量。用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现进程互斥或同步。这一对原语就是 PV 操作:
P 操作和 V 操作必须成对出现,缺少 P 操作就不能保证对共享内存的互斥访问,缺少 V 操作就会导致共享内存永远得不到释放、处于等待态的进程永远得不到唤醒。
信号是进程通信机制中唯一的 「异步」 通信机制,它可以在任何时候发送信号给某个进程。「通过发送指定信号来通知进程某个异步事件的发生,以迫使进程执行信号处理程序。信号处理完毕后,被中断进程将恢复执行」。用户、内核进程都能生成和发送信号
信号事件的来源主要有硬件来源和软件来源。所谓硬件来源就是说我们可以通过键盘输入某些组合键给进程发送信号,比如常见的组合键 Ctrl+C 产生 SIGINT 信号,表示终止该进程;而软件来源就是通过 kill 系列的命令给进程发送信号,比如 kill -9 1111 ,表示给 PID 为 1111 的进程发送 SIGKILL 信号,让其立即结束。
上面介绍的 5 种方法都是用于同一台主机上的进程之间进行通信的,如果想要「跨网络与不同主机上的进程进行通信」,那该怎么做呢?这就是 Socket 通信做的事情了(「当然,Socket 也能完成同主机上的进程通信」)
Socket 被翻译为「套接字」,它是计算机之间进行通信的一种约定或一种方式。通过 Socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。
从计算机网络层面来说,「Socket 套接字是网络通信的基石」,是支持 TCP/IP 协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议,本地主机的 IP 地址,本地进程的协议端口,远地主机的 IP 地址,远地进程的协议端口。
Socket 的本质其实是一个编程接口(API),是应用层与 TCP/IP 协议族通信的中间软件抽象层,它对 TCP/IP 进行了封装。它「把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面」。对用户来说,只要通过一组简单的 API 就可以实现网络的连接。
总结一下上面六种 Linux 内核提供的进程通信机制:
线程是CPU调度的最小单位,是最小的执行单元。 线程又叫做轻量级进程,线程从属于进程,是程序的实际执行者。
一个进程至少包含一个主线程,也可以有更多的子线程。
多个线程共享所属进程的资源,同时线程也拥有自己的专属资源、拥有自己的栈空间。
线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
一个进程中的各个线程都有共享的资源而且是完全开放的,那么在进程运行中会出现多个线程访问同一个公共资源的问题。这种现象我们称之为线程之间产生了资源竞争,这种竞争会导致程序异常甚至崩溃。
线程同步四种方法:互斥锁、信号量、条件变量、读写锁
又称互斥体或互斥量,是最简单的一种线程同步机制,顾名思义,当一个线程访问的时候,就会把资源“锁”上,直到访问结束才会解锁,给其他线程访问。互斥锁本身就是一个全局变量:unlock 和 lock
该线程负责的加锁,解锁也需要该线程。
又称信号灯,控制同时访问公共资源的线程数量,当线程数量小于等于1时,这种信号量可以叫二元信号量,同理多的时候,叫多元信号量,是指同一时刻最后只有这么多个线程可以访问该资源。
信号量的取值范围必须>=0;值得一提的是信号量可以执行加一和减一的操作,而且这种操作还是原子操作来实现的。原子操作,你可以理解为多个线程修改信号量,但是在修改值的过程中互不干扰。
具体操作流程:
信号量分类:二元信号量,计数信号量
1、二元信号量初始值为1,信号量的值只有0和1,一定程度上替代互斥锁进行线程同步。
2、计数信号量,初始值大于1,可以允许多个线程同一时间访问同一资源,起到限制访问个数的作用。
功能类似现实中的门,只有打开和关闭两种状态,对应条件中的成立与不成立两种判定,一旦关闭,所有线程都不得访问该资源,一旦打开,那就恢复执行。通常条件变量和互斥锁是搭配使用的。
条件变量的本质也是全局变量,它的功能是阻塞线程,直到收到条件成立的信号,被阻塞的程序才能继续执行。
具体流程:
为了避免多线程抢资源的情况发生,条件变量必须和互斥锁搭配使用。
如果很多线程只是进行读取操作,只有少部分是写操作(修改),可以使用读写锁。读写锁的核心思想是将线程访问共同数据发出的请求分类:
当有多个读线程的时候,他们可以同时访问,但是写线程就必须要等他们读完才能访问,反过来也是,只不过写线程必须要一个一个来访问。读线程访问时候,读写锁称之为读锁,写线程访问时候,读写锁称之为写锁。
死锁
死锁指的是线程一直被阻塞的情况。比如:给线程加上互斥锁但是忘了解锁,那就会出现一直阻塞的情况。
避免死锁的建议:
线程通信就是当多个线程共同操作共享的资源时,互相告知自己的状态以避免资源争夺。线程通信主要可以分为三种方式,分别为共享内存、消息传递和管道流。每种方式有不同的方法来实现:
线程的生命周期包含5个阶段,包括:新建、就绪、运行、阻塞、死亡。当线程进入运行状态后,一般的操作系统是采用抢占式的方式来让线程获得CPU。所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞、就绪之间切换。
1.等待I/O流的输入输出
2.等待网络资源,即网速问题
3.调用sleep()方法,需要等sleep时间结束
4.调用wait()方法,需要调用notify()唤醒线程
5.其他线程执行join()方法,当前线程则会阻塞,需要等其他线程执行完。
在多核场景下,如果是I/O密集型场景,就算开多个线程来处理,也未必能提升CPU的利用率,反而会增加线程切换的开销。另外,多线程之间假如存在临界区或者共享数据,那么同步的开销也是不可忽视的。协程,正好可以解决上面的相关问题。
协程是一种用户态的轻量级线程。在一个用户线程上可以跑多个协程,这样就提高了单核的利用率。协程不像进程或者线程,可以让系统负责相关的调度工作,协程是处于一个线程中,系统是无感知的,所以需要在该线程中阻塞某个协程的话,就需要手工进行调度。
线程是指进程内的一个执行单元,也是进程内的可调度实体。线程与进程的区别:
总结:
- IO密集型一般使用多线程或者多进程,
- CPU密集型一般使用多进程,
- 强调非阻塞异步并发的一般都是使用协程,当然有时候也是需要多进程线程池结合的,或者是其他组合方式。