进程与线程
# 多线程与多核芯片
# CPU
CPU主要和内存进行交互,从内存中提取指令并执行。
CPU的执行周期是:从内存提取第一条指令、解码并决定它的类型和操作数,执行,然后再提取、解码执行后续的指令。
每个CPU都有一组可以执行的特定指令集。由于访问内存获取执行指令或者数据要比执行指令花费的时间长,所以CPU内部通常包含有一些寄存器,用于保存关键变量和临时结果。
CPU的寄存器主要有:用于保存变量和临时结果的通用寄存器;用于指示下一条需要从内存提取指令的地址的程序计数器;用于执行内存中当前栈的顶端的堆栈指针寄存器;用于跟踪系统状态的程序状态字寄存器(该寄存器还会使用一个二进制位控制当前状态是内核态还是用户态)
# 多线程
多线程允许CPU保持两个不同的线程状态并且在纳秒级的时间内完成线程切换。
一个进程想要从内存中读取指令(需要经历过几个时钟周期),多线程CPU则可以切换到另一个线程。注:计算机处理器可以在每个时钟周期执行一条或者多条指令。
多线程不会真正的并行处理,在一个时刻只要一个进程在运行。
# 多核CPU
为了做到真正的并行,CPU芯片上可能具有四个、八个或者更多完整的处理器或内核。多核芯片在其上有效地承载了四个微型芯片,每个微型芯片都有自己独立的CPU。
每个核一次只能运行一个线程。
# 进程
一个进程就是一个正在执行的程序的实例,进程包括程序计数器、寄存器以及变量当前的值等信息。
在多道程序系统中,CPU会在进程间快速切换,使每个程序运行几十或者几百毫秒。严格意义上来说,在某一个瞬间,CPU只能运行一个进程,如果我们把时间定位为1秒的话,它就可能运行多个进程。
这种行为是伪并行的。它与**多处理器系统(该系统由两个或者多个CPU共享同一个物理内存)**有本质的区别。
# 进程的五种状态
# 进程同步
# 同步机制遵循的原则:
空闲让进
忙则等待:
有限等待:应该保证在有限时间内可以进入自己的临界区,以免进程陷入“死等”的状态。
让权等待:权指CPU。当进程不能进入自己的临界区,应该立即释放处理机,以免陷入“忙等”状态。
# 进程同步的机制
# 硬件同步机制
关中断,测试并建立指令---》其他访问进程必须不断进行测试,处于一种忙等的状态,不符合让权等待的原则。
# 信号量
# 管程
有自己名字的特殊模块,由关于共享资源的数据结构和在其上的操作过程组成,进程可调用管程的过程以操作管程中的数据结构;编译器复杂管程的互斥,设置条件变量及等待唤醒操作解决同步问题。每次只有一个进程进入管程,执行这组过程,使用共享资源,达到对共享资源访问的统一管理
管程提出的原因:信号量机制中,每个要访问临界资源的进程必须自备同步操作,这样使得大量的同步操作分散在各个进程中。这会给系统的管理带来麻烦,也会因为同步操作的使用不当而导致系统死锁。
利用共享数据结构抽象地表示系统中的共享资源,并且将对该共享数据结构实施的特定操作定义为一组过程。
# 进程间通信(IPC)
进程通信就是进程之间的信息交换。前面介绍的进程同步也需要在进程间交换一定的信息,有的地方也把进程同步归类为进程通信。不能说这是错误的,只能说这种归类不太准确,因为进程同步只能算是一种比较低级的进程通信。
进程同步手段的效率低,每次只能从缓冲区得到一个信息,并每次只能放入一个信息。
进程同步手段的通信对用户不透明,操作系统只为进程间通信提供了共享存储器,而关于一些设置以及进程同步互斥都必须由程序员自己去实现。
# 管道
所谓的管道,就是内核中的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,实际也就是从内核中读取这段数据。
管道传输的数据是无格式的流且大小受限。
其实Linux命令中的|竖线就是管道(匿名管道/无名管道):ps auxf | grep mysql
。它的功能是将前一个命令的输出作为后一个命令的输入。
Linux还有一个命令mkfifo
可以用来创造一种命名管道,也叫做FIFO,数据是先进先出的传输方式。mkfifo channnelName
。
管道传输数据是单向的,也就是我们常说的半双工通信。
# 管道的同步
管道是一个具有特定大小的缓冲区
操作系统会保证读写进程的同步
下游进程或者上游进程需要等另一方释放锁后才能操作管道。管道就相当于一个文件,同一时刻只能有一个进程访问
当管道为空时,下游进程读阻塞;当管道满时,上游进程写阻塞
管道不再被任何进程使用时,自动消失
# 匿名管道
匿名管道的创建,需要使用以下的系统调用:int pipe(int fd[2])
。
该系统调用创建一个匿名管道,并且返回了两个描述符。一个是管道的读取端描述符fd[0],另一个是管道的写入端描述符fd[1]。
对于匿名管道来说,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过fork来复制父进程fd文件描述符,来达到通信的目的。
# 命名管道-FIFO
对于命名管道来说,它可以在不相关的进程间相互通信。因为命名管道提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
通过 mknode() 系统调用或者 mkfifo() 函数建立命名管道。一旦建立,任何有访问权的进程都可以通过文件名将其打开和进行读写,而不局限于父子进程。
建立命名管道时,会在磁盘中创建一个索引节点,命名管道的名字就相当于索引节点的文件名。**索引节点设置了进程的访问权限,但是没有数据块。**命名管道实质上也是通过内核缓冲区来实现数据传输。有访问权限的进程,可以通过磁盘的索引节点来读写这块缓冲区。
当不再被任何进程使用时,命名管道在内存中释放,但磁盘节点仍然存在。
不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时自然也是从内核获取,同时通信数据都遵循先进先出的原则。
# 消息队列
前面介绍的管道通信方式效率较低,不适用于进程间频繁交换数据。
A进程要给B进程发送消息,A进程把数据放在对应的消息队列中就可以正常返回了,B进程需要的时候再去读取数据就可以。
消息队列是保存在内核中的消息链表,在发送数据时会分成一个个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,由消息发送方和接收方约定。
每个消息体都是固定大小的存储块,不像管道中的无格式字节流数据。如果进程从消息队列中读取了消息体,内核会删除该消息体。
消息队列允许一个或多个进程向它写入与读取消息。消息的发送者和接收者不需要同时与消息队列交互。消息会保存在队列中,直到接收者取回它。也就是说,消息队列是异步的,但这也造成了一个缺点,就是接收者必须轮询消息队列,才能收到最近的消息。
# 消息队列的缺点
消息队列不适合较大数据的传输。内核中每个消息体都有一个最大长度的限制,同时所有队列的全部消息体的总长度也有上限。
消息队列通信过程中,存在用户态和内核态之间拷贝数据的开销。
# 消息队列与通道的区别
消息队列和管道相比,相同点在于二者都是通过发送-接收的方式进行通信,并且数据都有最大长度限制。不同点在于消息队列的数据是有格式的,并且取消息进程可以选择接收特定类型的消息,而不是像管道中那样默认全部接收。
# 共享内存
共享内存很好地解决了消息队列的读取和写入过程中发生用户态和内核态之间拷贝数据的问题。
现代操作系统中,采用虚拟内存技术管理内存,也就是说每个进程都有自己独立的虚拟内存客供件,不同进程的虚拟内存映射到不同的物理内存。所以,即使进程A和进程B的虚拟地址一样,它们访问的物理内存地址也是不同的。
共享内存机制就是,拿出一块虚拟地址空间,映射到相同的物理内存中。
一个进程可以通过操作系统的系统调用,创建一块共享内存区;其他进程通过系统调用把这段内存映射到自己的用户地址空间中;之后各个进程向读写正常内存一样,读写共享内存。共享内存区只会驻留在创建它的进程地址空间内。
# 共享内存的优点?
共享内存的优点是简单且高效,访问共享内存区域和访问进程独有的内存区域一样快,原因是不需要系统调用,不涉及用户态到内核态的转换,也不需要对数据不必要的复制。
比如管道和消息队列,需要在内核和用户空间进行四次的数据拷贝(读输入文件、写到管道;读管道、写到输出文件),而共享内存则只拷贝两次:一次从输入文件到共享内存区,另一次从共享内存到输出文件(图示 (opens new window))。此外,消息传递的实现经常采用系统调用,也就经常需要用户态和内核态互相转换;而共享内存只在建立共享内存区域时需要系统调用;一旦建立共享内存,所有访问都可作为常规内存访问,无需借助内核。
# 共享内存的缺点?
共享内存的缺点是存在并发问题,有可能出现多个进程修改同一块内存,因此共享内存一般与信号量结合使用。
# 共享内存的实现方式?
Linux 的 2.2.x 内核支持多种共享内存方式,如 mmap() 系统调用,Posix 共享内存,以及系统 V 共享内存。
mmap() 系统调用的主要作用是将普通文件映射到进程的地址空间,然后可以像访问普通内存一样对文件进行访问,不必再调用 read(),write() 等操作。mmap() 不是专门用来共享内存的,但是多个进程可以通过 mmap() 映射同一个普通文件,来实现共享内存。
系统 V 则是通过映射特殊文件系统 shm 中的文件实现进程间的共享内存。通过 shmget 可以创建或获得共享内存的标识符。取得共享内存标识符后,通过 shmat 将这个内存区映射到本进程的虚拟地址空间。
# 信号量
使用了共享内存的通信方式,会带来新的问题,那就是多个进程会同时修改同一个共享内存,导致冲突的产生。
信号量机制可以防止多进程竞争共享资源,保证任意时刻只能被一个进程访问。
信号量是一个整数的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。信号量更像一种进程同步的机制和手段。
信号量是一种特殊的变量,对它的操作都是原子的,有两种操作:V(signal())和 P(wait())。V 操作会增加信号量 S 的数值,P 操作会减少它。
信号量表示资源的数量,有两种原子操作:
P操作:将信号量减去1,相减后如果信号量<0,则表示资源已经被占用,进程需要阻塞等待。
V操作:将信号量加上1,相加后如果信号量<=0,表示当前有阻塞的进程,于是会将该进程唤醒运行。
如果信号量是一个任意的整数,通常被称为计数信号量(Counting semaphore),或一般信号量(general semaphore);如果信号量只有二进制的 0 或 1,称为二进制信号量(binary semaphore)。在 Linux 系统中,二进制信号量又称互斥锁(Mutex)。信号量可以用于实现进程或线程的互斥和同步。
信号量在底层的实现是通过硬件提供的原子指令,如 Test And Set、Compare And Swap 等。比如 golang 实现互斥量就是使用了 Compare And Swap 指令(github (opens new window))。
# 信号
前面说的进程间通信都是常规下的工作模式,对于异常下的工作模式,则需要使用信号来通知进程。
信号和信号量虽然名字上相似,但是两者是完全不同的东西,用途也不一样。
我们常使用的命令:CTRL + C会产生SIGINT信号,表示终止该进程;而CTRL + Z则会产生SIGTSTP信号,表示停止该进程,但是还未结束。
如果进程在后台运行,可以通过kill命令的方式给进程发送信号,比如kill -9 1050
。
信号是进程间通信机制中唯一的异步通信机制。
# Socket
Socket通信可以实现跨网络与不同主机上的进程进行通信。
# 进程上下文切换
各个进程时共享CPU资源的,在不同的时候进程之间需要切换,让不同的进程可以在CPU执行。一个进程切换到另一个进程运行,称为进程的上下文切换。当一个进程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
任务是交给CPU运行的,在每个任务运行前,CPU需要知道任务从哪里加载,又从哪里开始运行。所以,操作系统需要事先帮CPU设置好CPU寄存器和程序计数器。
CPU上下文切换就是事先把前一个任务的CPU上下文(CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指定的新位置,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给CPU运行时,CPU会重新加载这些上下文,这样可以保证任务原来的状态不受影响,让任务看起来是连续运行。
上述的任务主要包含进程、线程以及中断。可以根据CPU上下文切换分成进程上下文切换、线程上下文切换以及中断上下文切换。
而进程的上下文切换的内容主要包括虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
通常,内核会把交换的信息保存在进程的PCB中,当要运行另外一个进程的时候,我们需要从这个进程的PCB中取出上下文,然后恢复到CPU中,使得这个进程可以继续执行。
需要注意的是:进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
# 线程
# 为什么需要在进程的基础上引入线程的概念
多线程之间会共享同一块地址空间和所有可用数据的能力,这是进程所不具备的。
线程要比进程更加轻量级,由于进程更轻,所以它比进程更容易创建也更容易撤销。在许多系统中,创建一个线程要比创建一个进程快10-100倍。
性能方面的探讨:如果多个线程都是CPU密集型的,那么并不能获得性能上的增强,但是存在着大量的计算和大量的I/O处理,拥有多个线程能在这些活动中彼此重叠进行,从而会加快应用程序的执行速度。
进程切换是一个开销很大的操作。进程切换的开销主要包括:
处理机的上下文切换:保存和恢复相关寄存器的内容
与进程相关的数据结构更改:存储管理有关的记录信息(如页表)、文件管理有关数据(如文件描述符)、进程控制块中的各种队列(如阻塞队列、就绪队列、通信队列)等
**进程的处理机资源和其他资源是一起分配的,进程切换的时候会整体切换,开销很大。**如果我们只切换必需的、与处理机相关的信息,就可以有效减少开销。这种情况下,处理机分配的单位和其他的资源分配的单位不能再是一个实体。
由此引入线程:把一个进程分为多个执行任务的单元体,只为其分配处理机,这些执行任务的单元体就是线程。
# 线程的三种实现方式
参考另一篇文章:https://www.yuque.com/hanchanmingqi-zjjw3/kb/vybrfx (opens new window)
# 在用户空间实现线程
优势:
考虑如果在线程完成时或者是调用pthread_yield时,必然会发生线程切换,然后线程的信息会被保存在运行时环境所提供的线程表中,然后线程调度程序来选择另外一个需要运行的线程。保存线程的状态和调度程序都是本地过程,所以启动它们比进行内核调用效率更高。因而不需要切换到内核,也就不需要进行上下文切换,所以线程调度非常迅捷。
允许每个进程自己定制调度算法。
劣势:
实现阻塞系统调用困难。比如,在还没有任何键盘输入之前,让一个线程读取键盘,让线程进行系统调用是不可能的,因为这会导致该进程中的所有线程发生阻塞。
此外,对于缺页中断的问题,用户级线程也很难实现。由于计算机不会把所有程序一次性放入内存,当某个程序发生函数调用或者跳转指令到了一条不在内存的指令上,就会发生缺页,这时需要系统到磁盘上取回丢失的指令。然而,在对所需要的指令进行读入和执行时,相关的进程就会被阻塞。内核由于不知道线程的存在,通常只有一个线程引起的缺页故障,但是内核会把整个进程阻塞直到磁盘I/O完成为止。
另一个问题就是,如果一个线程开始运行,该线程所在进程内的所有线程都不能运行,除非第一个线程自愿放弃CPU,在一个进程内部,不存在时钟中断的概念,所以不能使用轮询的方式调度线程。
# 在内核中实现线程
内核中的线程表持有每个线程的寄存器、状态和其他信息。这些信息和用户空间中的线程信息相同,但是位置却被放在内核而不是用户空间。此外,内核还维护了一种进程表用来跟踪系统状态。
所有能够阻塞的调用都会通过系统调用的方式来实现,当一个线程阻塞时,内核可以选择是运行同一个进程中的另一个线程还是运行另一个进程中的线程。
劣势:
在内核中创建和销毁线程的开销较大。
系统调用的代价较大,如果线程的操作比较多,容易带来很大的开销。
# 混合方式
# 线程上下文切换
线程拥有自己的私有数据,比如栈和寄存器,这些在上下文切换时需要保存起来。
当两个线程不是属于同一个进程,则切换的过程和进程上下文切换是一样的。
当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
线程有自己的寄存器和栈。当上下文切换的时候,正在运行的线程会将寄存器的状态保存到 TCB(Thread Control Block)里(进程是 PCB,Process Control Block),然后恢复另一个线程的上下文。
# 进程与线程的区别
进程用于将资源集中在一起,而线程则是CPU上调度执行的实体。具体来说,进程有存放程序正文和数据以及其他资源的地址空间。此外,进程中还拥有一个执行的线程。它包括程序计数器,用于记录接着要执行哪一条指令;线程还拥有寄存器,用于保存线程当前正在使用的变量;线程还有堆栈,用来记录程序的执行路径。
在一个进程中并行运行多个线程类似于在一台机器上运行多个进程。