《Go并发编程实战》系列一:多进程编程
并发与并行
并发程序是指可以被同事发起执行的程序,并行程序可以在并行的硬件上执行的并发程序,这两者稍有不同,并发程序代表了所有可以实现并发行为的程序,其中包含了并行程序。
串行程序所有代码的先后顺序都是确定的,并发程序中只有部分代码有序,其中有一些代码的执行顺序无明确指定,这被称为不确定性。
并发程序内部会被划分为多个部分,每个部分都是一个串行程序,这些串行程序中又会存在交互需求,我们需要协调他们的执行,就涉及到同步。
同步
同步的作用是避免在并发访问共享资源时可能发生的冲突,以及确保有条不紊的传递数据。如果程序想使用一个共享资源,就必须请求该资源并获取到对它的访问权,如果不需要时,应该放弃对该资源的访问权。即同一时刻某个资源应该只被一个程序所占用。
异步
传递数据是并发程序内部的一种交互方式,也成为并发程序内部的通信,协调这种内部通信的方式不只“同步”一种,我们也可使用异步的方式进行。这种方式可使数据不加延迟的发送给数据接收方。数据将会被临时存放在一个称为数据通信缓存的数据结构中。通信缓存是一种特殊的共享资源,他可同时被多个程序使用,数据结合搜房可以在准备就绪后按照数据的写入顺序进行接收。
多进程编程
进程间通信的方式用来支持多个进程间协作完成任务,这种通信也被称为IPC(Inter-Process Communication),Linux中支持的IPC有多种,可分为三类:
-
基于通信的IPC方法
其中又分为以数据传送为手段的IPC方法和以共享内存为手段的IPC方法,前者包括管道(PIPE)和消息队列(message queue),管道可以用来传送字节流,消息队列可以用来传送结构化的消息对象。以共享内存为手段的IPC方法主要以共享内存区(shared memory)为代表,它是最快的一种IPC方法。
-
基于信号的IPC方式
即操作系统的信号(signal)机制,它是唯一的一种异步IPC的方法。 -
基于同步的IPC方式,
即信号量(semaphore)
进程的定义
一个程序的执行叫做进程,进程也是操作系统进行资源分配的一个基本单位。
进程可使用(fork)创建若干个新的进程,前者称为后者的父进程,后者称为前者的子进程。每个子进程都源自父进程的一个副本,他会获得父进程的数据段、堆和栈的副本,并与父进程共享代码段。每一份副本都是独立的。子进程对副本的修改对其父进程和兄弟进程都是不可见的,反之亦然。Linux 操作系统内核使用写时复制(Copy on Write)等技术来提高进程创建的效率。
Unix/Linux中每一个进程都有父进程,所有的进程共同组成了一个树状结构。内核启动进程作为进程树的根,负责系统的初始化操作。
为了管理进程,内核必须对每个进程的属性和行为进行详细记录,包括进程的优先级、状态、虚拟地址范围以及各种访问权限等等,这些信息都会被记录在每个进程的进程描述符中。进程描述符是一个复杂的数据结构。保存在进程描述符中的进程ID(也被称为PID)是进程在操作系统的唯一标识。其中进程ID为1的进程就是内核启动进程。进程ID为一个非负整数。此外,进程描述符中海油当前进程的父进程的ID也被称为(PPID)。
我们可通过Go标准库代码查看当前进程的PID和PPID:
pid := os.Getpid()
ppid := os.Getppid()
进程的状态
Linux中每个进程在每个时刻都有状态,可能的状态有6个:可运行状态、可中断的睡眠状态、不可中断的睡眠状态、暂停状态或跟踪状态、僵尸状态和退出状态,具体每个状态的轮转可见参考文章
进程的空间
用户进程(程序的执行实例)总会生存在用户空间,无法与其所在计算机的硬件进行交互。内核可与硬件交互,但它生存在内核空间。用户进程无法直接访问内核空间。用户空间和内核空间瓜分了操作系统可支配的内存区域。
用户空间范围为0~TASK_SIZE,内核空间则占据了剩下的空间。TASK_SIZE由所在计算机体系结构确定,是一个常数,如图:
内存区域中的每一个单元都是有地址的,地址由指针来标识和定位。通过指针来寻找内存单元的操作也称为内存寻址。指针是一个正整数,由若干个二进制位来表示,具体的二进制位的数量由计算机(CPU)的字长来决定。如在32位计算机中可以有效标识2的32次方个内存单元,64位计算机中可以有效标识2的64次方个内存单元。
此处所说的地址并非物理地址,而是虚拟地址,而由虚拟地址来标识的内存区域又称为虚拟地址空间,也被称为虚拟内存。虚拟内存的最大容量与实际可用的物理内存的大小无关。CPU和内核会负责维护虚拟内存与物理内存之间的关系。另外,内核会为每个用户进程分配的是虚拟内存而不是物理内存。每个用户进程分配到的虚拟内存总是在用户空间中,而内核空间为内核专用。
内核会将进程的虚拟内存划分为若干页(page),而物理内存单元的划分由CPU负责。一个物理内存单元被称为一个叶框(page frame)。不同进程的大多数页都会与不同的叶框对应。
系统调用
用户进程使用用户空间和内核空间之间桥梁(允许用户进程使用内核功能的接口)的行为称为系统调用,与普通函数相比,系统调用是向内核空间发出的一个明确请求,普通函数只定义了如何获取一个给定的服务。系统调用会导致内核空间中数据的存取和指令的执行,而普通函数却只能在用户空间进行。此外,系统调用是内核的一部分。
内核态与用户态
为了确保系统安全,内核依据由CPU提供的、可以让进城驻留的特权级别建立了两个特权状态——内核态与用户态。大部分情况下,CPu都处于用户态,这时CPU只能对用户空间进行访问。换而言之,CPU在用户态下运行的用户进程是不能与内核接触的。
当用户进程发出一个系统调用时,内核会把CPU从内核态切换到用户态,而后让CPU执行对应的内核函数。这就相当于用户进程可以通过系统调用使用内核提供的功能,当内核函数执行完毕后,内核会把CPU从内核态切回用户态,并把执行结果返回给用户进程。
进程切换和调度
Linux操作系统可凭借CPU的威力快速地在多个进程之间进行切换,这被称为进程间的上下文切换,在进程换入换出期间必须要做的任务统称为进程切换。
为了使各个生存着的进程都有运行的机会,内核还要考虑下次切换时运行哪个进程、何时切换、换下的进程何时换上等等。解决类似问题的方案和任务统称为进程调度。常见的进程调度方法有:
1. 先来先去服务
2. 时间片轮转法
3. 多级反馈队列算法
4. 最短进程优先
5. 最短剩余时间优先
6. 最高响应比优先
7. 多级反馈队列调度算法
同步
同步这块有几个高频出现的概念:
- 执行过程中不能中断的操作称为原子操作(atomic operation)
- 只能被串行化访问或执行的某个资源或某段代码称为临界区(critical section)
PS: 所有系统调用都属于原子操作。
*原子操作不能中断,而临界区对是否可以被中断却无强制性规定,原子操作仅适合细粒度的简单操作*
在单核程序并发时,原子操作是一种很好的解决方案,不过让串行化执行的若干代码形成临界区的这种做法更加通用。保证只有一个进程或线程在临界区之内的做法有一个官方谓称——互斥(mutual exclusion,简称mutex)。实现互斥的方法必须确保排他原则(exclusion principle),并且保证不能依赖于任何计算机硬件来实现。作为IPC方法之一的信号量就属于实现互斥的可行方式之一。
Go语言中支持的IPC方法有管道、信号和socket
管道
管道(pipe)是一种半双工的通信方式,只能用于父进程与子进程以及通祖先的子进程之间的通信。
如:Shell命令中的管道,shell 为每个命令都创建一个进程,然后把左边命令的标准输出用管道与右边命令的标准输入连接起来。
ps aux|grep go
如上是匿名管道,与之对应的是命名管道(named pipe)。与匿名管道不同的是,任何进程都可以通过命名管道交换数据。
如上图,我们创建了一个名为myfifo1的命名管道,从src.log中读入数据,最后写入dst.log中
PS:
1.命名管道默认是阻塞式的。具体来说,只有在对这个命名管道的读操作和写操作都已准备就绪后,数据才开始流转。
2.匿名管道会在管道缓冲区被写满后使写数据的进程阻塞,命名管道会在其中一段未就绪前阻塞另一端的进程。
3.命名管道可以被多路复用。
go语言中创建管道的语句
reader, writer := io.Pipe()
信号
信号(signal)是IPC中唯一一种异步的通信方式,他的本质使用软件来模拟硬件的中断机制。信号用来通知有某个事件发生了。
在Linux下,我们可使用kill命令来查看当前系统所支持的信号。
可以看到,每一个信号都有一个以“SIG”为前缀的名字。如SIGINT、SIGQUIT等。Linux支持的信号有62种(无32和33的信号)。其中,编号从1到31的信号属于标准信号(不可靠信号),编号34到64的信号属于实时信号(也称可靠信号)。对于同一个进程来说,每种标准信号只会被记录并处理一次。并且如果发送给某一个进程的标准信号的种类有多个,那么他们的处理顺序也是完全不确定的。而实时信号解决了标准信号的这两个问题,即多个同种类的实时信号都可以记录在案,并且它们可以按照信号的发送顺序被处理。虽然实时信号在功能上更加强大,但已成为事实标准的标准信号也无法被替换掉。因而这两种信号一直存在。
信号的来源有键盘输入、硬件故障、系统函数、软件中的非法运算。进程响应信号的方式有三种:忽略、捕捉和执行默认操作。
Linux对每一个标准信号都有默认的操作方式。包括:终止进程、忽略该信号、终止进程并保存内存信息、停止进程、恢复进程。
在Go语言中,Go命令会对其中一些以键盘输入为来源的标准信号作出响应,这是通过标准库代码包os/signal中的一些API实现的。即Go命令指定了需要被处理的信号并用一种很优雅的方式来监听信号的到来。
接口os.Signal
type Signal interface{
String() string
Signal()
}
利用Go提供的信号处理接口,我们可以处理信号传来的数据
Socket
套接字,可以通过网络连接让多进程建立通信并相互传递数据,这使得通信双方是否在同一台计算机上变得无关紧要。即实现了让通信端的位置透明化。
在Linux系统中,存在一个名为socket的系统调用,其声明如下:
int socket(int domain, int type, int protocal);
该系统调用的功能是创建一个socket实例。接受了3个参数,分别代表这个socket的通信域、类型和所用协议。