本篇文章记录的是在学习操作系统的过程中的一些总结和笔记,本篇重点讲解进程、线程和协程的概念,以及三者之间的关系等等。
提示:以下是本篇文章正文内容,下面案例可供参考
为了在讨论进程之前对其有一个大概的了解,首先不可避免的一个话题就是操作系统,我们都知道,现代的计算机软件都是基于操作系统之上来运行的,操作系统封装了计算机底层的硬件资源(如键盘、显示器、磁盘或主存等),当我们的应用程序想要访问和使用这些计算机资源的时候,唯一的方式就是依靠操作系统提供的服务(系统调用),进而在计算机硬件资源、操作系统以及应用程序之间有以下的一种关系
操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现两个基本的功能:1)防止硬件被失控的应用程序滥用,2)向应用程序提供简单一致的机制来控制复杂而又大不相同的低级硬件设备。从宏观的角度来讲,文件是对IO设备的抽象,虚拟内存是对主存和磁盘IO设备的抽象,进程则是对处理器、主存和IO设备的抽象表示。下面我们重点讨论进程的概念,另外两个其他的概念以后再讨论。
在程序运行的时候,操作系统提供了一种假象,使得程序认为自己是独占整个系统资源的(处理器、主存和IO设备),这种假象是通过进程来实现的。当然,进程的定义各说纷云,本质上来说,进程是操作系统对一个正在运行的程序的一种抽象。一个系统里面可以同时运行多个进程,而每一个进程又好像是在独占的使用硬件资源,这是通过处理器在进程之间不停的切换来实现的。操作系统实现这种交错运行的机制称为上下文切换。这里在另外的补充一点,进程之间的切换是有操作系统内核管理的,而内核就是操作系统的核心代码,常驻在主存部分。进程的抽象概念在代码实现中是使用进程控制块(PCB)来实现的,在内核代码中就是一个包含了进程运行时的各种数据的结构体(task_struct)。
这里在结合上面说到的系统调用(system call)来做一个整体的总结,当应用程序需要操作系统的某些操作的时候,他就会执行一条特殊的系统调用指令,将控制权传递给内核(有用户态转为内核态),然后内核执行被请求的操作并且返回给应用程序。
Linux理论上可以创建多少个进程?
我们知道,每一个进程都有一个进程标识符(pid),而一般的Linux系统都是使用pid_t的类型来表示pid变量的,而pid_t类型一般都是int类型的,因此,最大的进程数量取决于pid_t变量的最大值。当然凡事都不会有绝对的,也可以通过系统配置文件来进行修改这个值的上限
相比于进程来说,线程的概念就相对而言简单一些。一般来说,进程是系统资源分配和调度的基本单位,线程是处理器(CPU)资源分配和调度的基本单位。通常情况下,一个进程里面只有一个控制流,但是在现代的系统中,为了充分的利用系统资源,一个进程实际上是可以有多个称为线程的执行单元的,每一个线程都运行在进程的上下文(进程地址空间)中,一个进程中的线程除了自己独有的数据之外,都是共享同一个进程里面的数据的,故在多线程之间共享数据比在多进程之间共享数据容易很多。进程在实现上有分为内核级线程和用户级线程,以及内核级和用户级混合的线程。在Linux系统中,是没有真正意义上的线程的,它将线程的实现为一个轻量级的进程,其通过在进程之间共享数据的方式来实现线程的。相对于Linux系统的其他系统,比如windows系统就实现了区别于进程的线程。
进程和线程的区别和联系
区别如下:
联系如下:
进程与线程之间的关系:线程是存在进程的内部,一个进程中可以有多个线程,一个线程只能存在一个进程中。
进程之间私有和共享的资源:
线程之间私有和共享的资源:
进程之间的通信方式主要有六种:管道、信号量、消息队列、信号、共享内存和套接字,下面从数据共享和状态同步开始来依次讲解各个通信方式。
资源的共享会造成数据的竞争问题,为了避免这种竞争问题的方式就是禁止多个进程在同一时刻对共享资源进行读写操作。也就是说,我们需要一种可以阻止多个进程同时去读写共享资源,最简单的实现方式就是使用互斥条件,此方式可以保证多个进程不会同时去读写共享资源。
为了讨论的方便,人为的把对共享内存进行访问的程序片段称作临界区域(critical region)或临界区(critical section)。只要保证使得不同的进程不能同时处于临界区,即可避免数据竞争问题。
尽管临界区的设计避免了竞争问题,但是不能确保并发线程同时访问共享数据的正确性和高效性,因此,进一步抽象得到一个好的解决方案应该包含以下必要条件:
在上一小节讨论了实现临界区的方案是使用互斥,本节讨论实现互斥的各种方案,在这些方案中,当一个进程正忙于更新其临界区的共享内存的时候,保证了没有其他进程会进入临界区。
最简单的实现方案是屏蔽中断,在进程进入到临界区的时候立刻屏蔽中断,在离开临界区的时候再重启。因为屏蔽中断后,系统的时钟中断也会被屏蔽掉,而CPU只会在发生时钟中断或其他系统中断的时候才会进行进程间的切换,这样就导致了CPU不会切换到其他的进程去执行,只会执行当前的进程。
上述方案存在的一个问题是,如果经过长时间的执行后,进程仍然没有离开临界区,那么系统中断就一直启动不了,如果此时是单处理器系统,那么整个系统就会终止服务,进而违反了在上一节提到的条件–不能使任何进程无限等待进入临界区。如果是多处理器的话,屏蔽中断仅仅对执行disable指令的CPU有效,其他的CPU仍然可以继续执行,并且可以访问共享的内存区域。
故一般的情况下,屏蔽中断只会用于内核执行期间,而对于用户进程或线程来说,屏蔽中断并不是一项通用的互斥解决方案。
在软件层面也有对应的解决方案,考虑如下的场景,使用一个共享的变量(锁)来记录进程进入临界区的状态,将锁变量初始化为0,当一个进程想要进入临界区的时候会执行以下的逻辑:
存在的问题:由于这个锁变量不是原子性的操作,因此对于锁变量的读写会存在竞争问题
// 进程0的代码
while(TRUE){
while(turn == 0){
/* 进入关键区域 */
critical_region();
turn = 1;
/* 离开关键区域 */
noncritical_region();
}
}
//进程1的代码
while(TRUE){
while(turn == 1){
critical_region();
turn = 0;
noncritical_region();
}
}
在上面的代码中,使用turn来记录轮到哪个进程进入临界区,进程会在不停的检测turn的值,这种连续检查一个变量的值知道某个特殊的值出现的情况称为忙等待(busying waiting)。这种忙等待的方式是浪费CPU时间的,只有在有理由认为等待时间是非常短的情况下,才能够使用忙等待,用于忙等待的锁称为自旋锁(spinlock)。
存在的问题:对于turn变量的操作是不存在保护措施的,这就导致另一个数据竞争问题。
将锁变量与警告变量相结合,最早提出了一个不需要严格轮换的软件互斥算法,后来, G.L.Peterson 发现了一种简单很多的互斥算法,它的算法如下:
#define FALSE 0
#define TRUE 1
/* 进程数量 */
#define N 2
/* 现在轮到谁 */
int turn;
/* 所有值初始化为 0 (FALSE) */
int interested[N];
/* 进程是 0 或 1 */
void enter_region(int process){
/* 另一个进程号 */
int other;
/* 另一个进程 */
other = 1 - process;
/* 表示愿意进入临界区 */
interested[process] = TRUE;
turn = process;
/* 空循环 */
while(turn == process
&& interested[other] == true){}
}
void leave_region(int process){
/* 表示离开临界区 */
interested[process] == FALSE;
}
在使用共享变量时(即进入其临界区)之前,各个进程使用各自的进程号 0 或 1 作为参数来调用 enter_region,这个函数调用在需要时将使进程等待,直到能够安全的进入临界区。在完成对共享变量的操作之后,进程将调用 leave_region 表示操作完成,并且允许其他进程进入。
一些计算机也提供了硬件层面的解决方案,如下所示:
TSL RX,LOCK
上述指令称为测试并加锁(test and set lock),将一个内存字lock读到寄存器RX 中,然后在该内存地址上存储一个非零值。该指令保证了读和写是一体的不能分割,一同执行的。此指令是通过锁住内存总线的方式来禁止其他CPU在这指令结束之前访问内存。
与之前的屏蔽中断的方式不一样的是,禁用中断并不能保证一个处理器在读写操作之间另一个处理器对内存的读写。让其他处理器无法读写的最好的方式就是锁住内存总线。
为了使用 TSL 指令,要使用一个共享变量 lock 来协调对共享内存的访问。当 lock 为 0 时,任何进程都可以使用 TSL 指令将其设置为 1,并读写共享内存。当操作结束时,进程使用 move 指令将 lock 的值重新设置为 0 。
这条指令如何防止两个进程同时进入临界区呢?下面是解决方案
enter_region:
| 复制锁到寄存器并将锁设为1
TSL REGISTER,LOCK
| 锁是 0 吗?
CMP REGISTER,#0
| 若不是零,说明锁已被设置,所以循环
JNE enter_region
| 返回调用者,进入临界区
RET
leave_region:
| 在锁中存入 0
MOVE LOCK,#0
| 返回调用者
RET
进程在进入临界区之前会先调用 enter_region,判断是否进行循环,如果lock 的值是 1 ,进行无限循环,如果 lock 是 0,不进入循环并进入临界区。在进程从临界区返回时它调用 leave_region,这会把 lock 设置为 0 。
上面解法中的 Peterson和TSL解法都是正确的,但是它们都有忙等待的缺点。这些解法的本质上都是一样的,先检查是否能够进入临界区,若不允许,则该进程将原地等待,直到允许为止。
这种方式不但浪费了 CPU 时间,而且还可能引起意想不到的结果。考虑一台计算机上有两个进程,这两个进程具有不同的优先级,H 是属于优先级比较高的进程,L 是属于优先级比较低的进程。进程调度的规则是不论何时只要 H 进程处于就绪态 H 就开始运行。在某一时刻,L 处于临界区中,此时 H 变为就绪态,准备运行(例如,一条 I/O 操作结束)。现在 H 要开始忙等,但由于当 H 就绪时 L 就不会被调度,L 从来不会有机会离开关键区域,所以 H 会变成死循环,有时将这种情况称为优先级反转问题(priority inversion problem)。
现在让我们看一下进程间的通信原语,这些原语在不允许它们进入关键区域之前会阻塞而不是浪费 CPU 时间,最简单的是 sleep 和 wakeup。Sleep 是一个能够造成调用者阻塞的系统调用,也就是说,这个系统调用会暂停直到其他进程唤醒它。wakeup 调用有一个参数,即要唤醒的进程。还有一种方式是 wakeup 和 sleep 都有一个参数,即 sleep 和 wakeup 需要匹配的内存地址。
作为sleep 和 wakeup原语的应用,可以考虑生产者和消费者问题。
管道是半双工的,双方需要通信的时候,需要建立两个管道。管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据:管道一端的进程顺序地将进程数据写入缓冲区,另一端的进程则顺序地读取数据,该缓冲区可以看做一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次,读出以后在缓冲区都不复存在了。当缓冲区读空或者写满时,有一定的规则控制相应的读进程或写进程是否进入等待队列,当空的缓冲区有新数据写入或满的缓冲区有数据读出时,就唤醒等待队列中的进程继续读写。管道是最容易实现的。管道又分为有名管道(命名管道)和无名管道(匿名管道)。
一个管道实际上就是个只存在于内存中的文件,对这个文件的操作要通过两个已经打开文件进行,它们分别代表管道的两端。
管道是一种特殊的文件,它不属于某一种文件系统,而是一种独立的文件系统,有其自己的数据结构。
管道实现机制:
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。信号量只有等待和发送两种操作。等待(P(sv))就是将其值减一或者挂起进程,发送(V(sv))就是将其值加一或者将进程恢复运行。
信号是Linux系统中用于进程之间通信或操作的一种机制,信号可以在任何时候发送给某一进程,用来通知进程某个事件已经发生,而无须知道该进程的状态。如果该进程并未处于执行状态,则该信号就由内核保存起来,直到该进程恢复执行并传递给他为止。如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程。
一个发出而没有被接收到的信号叫做待处理信号,在任何时候,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,而是被简单的丢弃。
共享内存允许两个或多个进程共享一个给定的存储区,这一段存储区可以被两个或两个以上的进程映射至自身的地址空间中,就像由malloc()分配的内存一样使用。一个进程写入共享内存的信息,可以被其他使用这个共享内存的进程,通过一个简单的内存读取读出,从而实现了进程间的通信。共享内存的效率最高,缺点是没有提供同步机制,需要使用锁等其他机制进行同步。
套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。借助于网络(TCP/IP)来进行进程之间的通信。
下面大致的介绍进程的常见调度方法,由操作系统内核中的调度代码部分决定的。
锁机制:包括互斥锁/量(mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(condition)
与进程之间通信使用的信号量一样,也是一个类似于计数器的机制,用来控制多个线程对共享资源的访问,根据信号量的大小,可以分为两种信号量:
信号量在创建时需要设置一个初始值,表示同时可以有几个任务可以访问该信号量保护的共享资源,初始值为1就变成互斥锁Mutex,即同时只能有一个任务可以访问信号量保护的共享资源
屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制
近几年出现的一个在高性能、高并发场景下使用的概念:协程。一般称协程为轻量级线程。对于操作系统来说,内核只“认识”进程和线程,对于协程的存在,内核是不知道的,需要用户负责协程的创建、调度和销毁,因此,协程又被称为用户级线程。以下简要的介绍几点协程的性质:
目前,协程的使用越来越广泛了,针对于高并发网络服务器的解决方案中就可以采用协程来实现。当然,手动实现一个高性能的协程还是有一定的难度的,因此,目前的很多编程语言都内置了协程的实现(例如:golang)。
上述的内容从概念的角度简要的介绍了计算机中重要的几个概念:进程、线程和协程,以及其引申出来的一些常见的知识点。