进程和线程有什么区别?

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,它是系统进行资源分配的基本单位(最小单位)、是系统调度的独立单位。

线程是进程的一个实体,是系统调度的基本单位(最小单位)。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器、一组寄存器和栈),但是它可以与同属于一个进程的其他线程共享进程所拥有的全部资源。在没有实现线程的操作系统中,进程既是资源分配的基本单位,又是系统调用的基本单位,它是系统中并发执行的单元;而在实现了线程的操作系统中,进程是资源分配的基本单位,而线程是系统调度的基本单位,是系统中并发执行的单元。

引入线程主要有以下四个方面的优点:

  1. 易于调度

  2. 提供并发性。通过线程可以方便有效地实现并发

  3. 开销小。创建线程比创建进程要快,所需要的开销也更小

  4. 有利于发挥多处理器的功能。通过创建多线程,每个线程可以在一个处理器上运行,从而实现应用程序的并行,使每个处理器都得到充分的运行。

尽管进程和线程很相似,但两者也存在着很大的不同,区别如下:

  1. 一个线程必定属于也只能属于一个进程;而一个进程可以拥有多个线程且至少拥有一个线程

  2. 属于一个进程的所有线程共享该进程的所有资源,包括打开的文件,创建的 socket 等。不同的进程互相独立

  3. 线程又被称为轻量级进程。进程有进程控制块,线程也有线程控制块。但线程控制块比进程控制块小得多。线程间切换代价小,进程间切换代价大

  4. 进程是程序的一次执行,线程可以理解为程序中一个片段的执行

  5. 每个进程都有独立的内存空间,而线程共享其进程所有的内存空间

引申:程序、进程和线程的区别是什么?

名称 描述
程序 一组指令的有序结合,是静态的指令,是永久存在的
进程 具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单元,是一个动态概念
线程 进程的一个实体,是CPU调度的基本单元,是比进程更小的能独立运行的基本单元。本身基本上不拥有系统资源,只拥有一个在运行中必不可少的资源(如程序计数器、一组寄存器和栈)。一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行

注意:一个程序至少有一个进程,一个进程至少有一个线程!

线程同步有哪些机制?

现在流行的进程/线程同步、互斥的控制机制,其实是由最原始、最基本的四种方法(临界区、互斥量、信号量和事件)实现的。

  1. 临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在任意时刻只允许一个线程访问共享资源,如果有多个线程试图访问共享资源,那么当有一个线程进入后,其他试图访问共享资源的线程将会被挂起,并一直等到进入临界区的线程离开,临界区在被释放后,其他线程才可以抢占。

  2. 互斥量:为协调对一个共享资源的单独访问而设计,只有拥有互斥量的线程才有权限去访问系统的公共资源,因为互斥量只有一个,所以能够保证资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享。

  3. 信号量:为控制一个具有有限数量的用户资源而设计。它允许多个线程在同一个时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目。

  4. 事件:用来通知线程有一些事件已发生,从而启动后继任务的开始。

内核线程和用户线程的区别

根据操作系统内核是否对线程可感知,可以把线程分为内核线程和用户线程。

引入用户线程主要有以下四个方面的优势:

  1. 可以在不支持线程的操作系统中实现

  2. 创建和销毁线程、线程切换等线程管理的代价比内核线程少得多

  3. 允许每个进程定制自己的调度算法,线程管理比较灵活

  4. 线程能够利用的表空间和堆栈空间比内核线程多

用户线程的缺点主要有以下两点:

  1. 同一进程中只能有一个线程在运行,如果有一个线程使用了系统调用而阻塞,那么整个进程都会被挂起

  2. 页面失效也会导致整个进程都被挂起

多线程编程

互斥的概念

临界区:它是访问共享资源的代码片段,一定不能给多线程同时执行。

互斥:一个线程在临界区执行时,其他线程被阻止进入临界区。

另外,说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。

同步的概念

互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的,只要一个进程/线程进入了临界区,其他试图想进入临界区的进程/线程都会被阻塞着,直到第一个进程/线程离开了临界区。

例子,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。

同步:并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待和互通消息称为进程/线程同步。

吃饭与做菜的同步关系

注意,同步与互斥是两种不同的概念:

  • 同步就好比:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等

  • 互斥就好比:「操作A和操作B不能在同一时刻执行」

互斥和同步的实现和使用

在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。

为了实现进程/线程间正确的协作,操作系统必须提供实现进程/线程间协作的措施和方法,主要的方法有两种:

  • 锁:加锁、解锁操作;

  • 信号量:P、V 操作;

这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。

根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。

我们先来看看「忙等待锁」的实现

那什么是原子操作呢?原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态。

忙等待锁也称为自旋锁。

再来看看「无忙等待锁」的实现

无忙等待锁顾名思义就是获取不到锁的时候,不用自旋。

既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。

信号量

信号量是操作系统提供的一种协调共享资源访问的方法。

通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。

另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:

  • P 操作:将 sem 减 1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;

  • V 操作:将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;

操作系统是如何实现 PV 操作的呢?

PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行 PV 函数时是具有原子性的。

PV 操作如何使用的呢?

信号量不仅可以实现临界区的互斥访问控制,还可以实现线程间的事件同步。

我们先来说说如何使用信号量实现临界区的互斥访问

为每类共享资源设置一个信号量 s,其初值为 1,表示该临界资源未被占用。

只要把进入临界区的操作置于 P(s)V(s) 之间,即可实现进程/线程互斥:

对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示:

  • 如果互斥信号量为 1,表示没有线程进入临界区;

  • 如果互斥信号量为 0,表示有一个线程进入临界区;

  • 如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。

通过互斥信号量的方式,就能保证临界区任何时刻只有一个线程在执行,就达到了互斥的效果。

再来,我们说说如何使用信号量实现事件同步

同步的方式是设置一个信号量,其初值为 0。

我们把前面的「吃饭-做饭」同步的例子,用代码的方式实现一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
semaphore s1 = 0; // 表示不需要吃饭
semaphore s2 = 0; // 表示饭还没做完

//儿子线程函数
void son( )
while( TRUE )
{
肚子饿;
V(s1); //叫妈妈做饭
P(s2); //等待妈妈做完饭
吃饭;
}
}

//妈妈线程函数
void mon( )
{
while( TRUE )
{
P(s1);//询问需不需要做饭
做饭;
V(s2); //做完饭,通知儿子吃饭
}
}

妈妈一开始询问儿子要不要做饭时,执行的是 P(s1) ,相当于询问儿子需不需要吃饭,由于 s1 初始值为 0,此时 s1 变成 -1,表明儿子不需要吃饭,所以妈妈线程就进入等待状态。

当儿子肚子饿时,执行了 V(s1) ,使得 s1 信号量从 -1 变成 0,表明此时儿子需要吃饭了,于是就唤醒了阻塞中的妈妈线程,妈妈线程就开始做饭。

接着,儿子线程执行了 P(s2) ,相当于询问妈妈饭做完了吗,由于 s2 初始值是 0,则此时 s2 变成 -1,说明妈妈还没做完饭,儿子线程就进入等待状态。

最后,妈妈终于做完饭了,于是执行 V(s2) ,s2 信号量从 -1 变回了 0,于是就唤醒等待中的儿子线程,唤醒后,儿子线程就可以开始吃饭了。

生产者、消费者问题

生产者、消费者模型

生产者-消费者问题描述:

  • 生产者在生成数据后,放在一个缓冲区中;

  • 消费者从缓冲区取出数据处理;

  • 任何时刻,只能有一个生产者或消费者可以访问缓冲区;

我们对问题分析可以得出:

  • 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;

  • 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。

那么我们需要三个信号量,分别是:

  • 互斥信号量 mutex :用于互斥访问缓冲区,初始化值为 1;

  • 资源信号量 fullBuffers :用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空);

  • 资源信号量 emptyBuffers :用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n (缓冲区大小);

具体的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#define N 100
semaphore mutex = 1 ; //互斥信号量,用于临界区的互斥访问
semaphore emptyBuffers = N; //表示缓冲区「空槽」的个数
semaphore fullBuffers = 0; //表示缓冲区「满槽」的个数

//生产者线程函数
void producer( )
while( TRUE )
{
P( emptyBuffers); //将空槽的个数- 1
P( mutex); //进入临界区
将生成的数据放到缓冲区中;
V( mutex); //离开临界区
V( fullBuffers); //将满槽的个数+ 1
}
}

//消费者线程函数
void consumer( )
while( TRUE )
{
P( fullBuffers); //将满槽的个数- 1
P( mutex); //进入临界区
从缓冲区里读取数据;
V( mutex); //离开临界区
V( emptyBuffers); //将空槽的个数+ 1
}
}

如果消费者线程一开始执行 P(fullBuffers),由于信号量 fullBuffers 初始值为 0,则此时 fullBuffers 的值从 0 变为 -1,说明缓冲区里没有数据,消费者只能等待。

接着,轮到生产者执行 P(emptyBuffers),表示减少 1 个空槽,如果当前没有其他生产者线程在临界区执行代码,那么该生产者线程就可以把数据放到缓冲区,放完后,执行 V(fullBuffers) ,信号量 fullBuffers 从-1 变成 0,表明有「消费者」线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。

消费者线程被唤醒后,如果此时没有其他消费者线程在读数据,那么就可以直接进入临界区,从缓冲区读取数据。最后,离开临界区后,把空槽的个数 + 1。

经典同步问题

哲学家就餐问题

哲学家就餐问题

先来看看哲学家就餐的问题描述:

  • 5 个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面;

  • 巧就巧在,这个桌子只有 5 支叉子,每两个哲学家之间放一支叉子;

  • 哲学家围在一起先思考,思考中途饿了就会想进餐;

  • 奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐;

  • 吃完后,会把两支叉子放回原处,继续思考;

那么问题来了,如何保证哲学家们的动作有序进行,而不会出现有人永远拿不到叉子呢?

方案一:使用信号量的方式,即PV操作来尝试解决。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define N 5                     //哲学家个数
semaphore fork[5]; // 信号量初值为 1
//也就是叉子的个数
void smart_ person( int i) // i为哲学家编号0-4
{
while( TRUE )
{
think( ); //哲学家思考
P( fork[i] ); //去拿左边的叉子
P(fork[(i + 1) % N ]); //去拿右边的叉子
eat( ); // 进餐
V(fork[i] ); //放下左边的叉子
V(fork[(i + 1) % N ]); //放下右边的叉子
}
}

方案一的问题:假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了。这样就没有人可以拿到右边的叉子,即每一位哲学家都会在 P(fork[(i+1)%N]) 这条语句阻塞了,就发生了死锁的现象。

方案二:既然「⽅案⼀」会发⽣同时竞争左边叉⼦导致死锁的现象,那么我们就在拿叉⼦前,加个互斥信号量。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define N 5                 //哲学家个数
semaphore fork[5]; //每个叉子一个信号量,初值为1
semaphore mutex; //互斥信号量,初值为1

void smart_ person( int i) // i为哲学家编号0-4
{
while( TRUE )
think( ); //哲学家思考
P( mutex ); //进入临界区
P(fork[i] ); //去拿左边的叉子
P(fork[(i + 1) % N ]); //去拿右边的叉子
eat( ) ; // 进餐
V(fork[i] ); //放下左边的叉子
V(fork[(i + 1) % N ]); //放下右边的叉子
V( mutex); //退出临界区
}

方案二的问题:虽然能让哲学家们按顺序吃饭,但是每次进餐只能有一位哲学家,而桌面上是有5把叉子,按道理可以让两个哲学家同时进餐的,所以从效率角度上,这不是最好的解决方案。

方案三:让偶数编号的哲学家「先拿左边的叉⼦后拿右边的叉⼦」,奇数编号的哲学家「先拿右边的叉⼦后拿左边的叉⼦」。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define N 5                 //哲学家个数
semaphore fork[5]; //每个叉子一个信号量,初值为1

void smart_person(int i) // i为哲学家编号0-4
{
while( TRUE )
{
think( ); //哲学家思考
if( i%2==0 )
{
P(fork[i] ); //去拿左边的叉子
P(fork[(i + 1) % N ]); //去拿右边的叉子
}
else
{
P(fork[(i+1)%N]); //去拿右边的叉子
P(fork[i] ); //去拿左边的叉子
}
eat( ) ; //哲学家进餐
V(fork[i]); //放下左边的叉子
V(fork[(i + 1) % N ]); //放下右边的叉子
}
}

方案三既不会出现死锁,也可以两人同时进餐。

方案四:在这⾥再提出另外⼀种可⾏的解决⽅案,我们⽤⼀个数组 state 来记录每⼀位哲学家在进餐、思考还是饥饿状态(正在试图拿叉⼦)。那么,⼀个哲学家只有在两个邻居都没有进餐时,才可以进⼊进餐状态。第 i 个哲学家的左邻右舍,则由宏 LEFTRIGHT 定义:

LEFT : ( i + 5 - 1 ) % 5

RIGHT : ( i + 1 ) % 5

⽐如 i 为 2,则 LEFT 为 1, RIGHT 为 3。

具体代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#define N 5                 // 哲学家个数
#define LEFT(i+N-1) % N // i的左边邻居编号
#define RIGHT(i + 1) % N // i的右边邻居编号

#define THINKING 0 // 思考状态
#define HUNGRY 1 // 饥饿状态
#define EATING 2 // 进餐状态

int state[N]; // 数组记录每个哲学家的状态

semaphore s[5]; // 每个哲学家一个信号量,初值0
semaphore mutex; // 互斥信号量,初值为1

void test(int i) // i为哲学家编号0-4
{
//如果i号的左边右边哲学家都不是进餐状态,把i号哲学家标记为进餐状态
if ( state[i] == HUNGRY &&
state[LEFT] != EATING &&
state[RIGHT] != EATING )
{
state[i] = EATING // 两把叉子到手,进餐状态
V(s[i]); // 通知第i哲学家可以进餐了
}
}

//功能:要么拿到两把叉子,要么被阻塞起来
void take_ forks(int i) // i为哲学家编号0-4
{
P(mutex ); // 进入临界区
state[i] = HUNGRY; // 标记哲学家处于饥饿状态
test(i); // 尝试获取2支叉子
V( mutex ); // 离开临界区
P(s[i]); // 没有叉子则阻塞,有叉子则继续正常执行
}

//功能:把两把叉子放回原处,并在需要的时候,去唤醒左邻右舍
void put_ forks(int i) // i为哲学家编号0-4
{
P( mutex ); // 进入临界区
state[i] = THINKING; // 吃完饭了,交出叉子,标记思考状态
test(LEFT); // 检查左边的左邻右舍是否在进餐,没则唤醒
test(RIGHT ); // 检查右边的左邻右舍是否在进餐,没则唤酲
V( mutex ); // 离开临界区
}

//哲学家主代码
void smart_ person(int i) // i为哲学家编号0-4
{
while( TRUE)
{
think( ); // 思考
take_forks(i); // 准备拿去叉子吃饭
eat( ); // 就餐
put_forks(i); // 吃完放回叉子
}
}

方案四既不会出现死锁,也可以两人同时进餐。

读者-写者问题

前面的「哲学家进餐问题」对于互斥访问有限的竞争问题(如 I/O 设备)一类的建模过程十分有用。

另外,还有个著名的问题是「读者-写者」,它为数据库访问建立了一个模型。

读者只会读取数据,不会修改数据,而写者即可以读也可以修改数据。

读者-写者的问题描述:

  • 「读-读」允许:同一时刻,允许多个读者同时读

  • 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写

  • 「写-写」互斥:没有其他写者时,写者才能写

接下来,提出几个解决方案来分析分析。

方案一:使⽤信号量的⽅式来尝试解决:

  • 信号量 wMutex :控制写操作的互斥信号量,初始值为 1 ;

  • 读者计数 rCount :正在进⾏读操作的读者个数,初始化为 0;

  • 信号量 rCountMutex :控制对 rCount 读者计数器的互斥修改,初始值为 1;

接下来看看代码的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
semaphore wMutex ;      // 控制写操作的互斥信号量,初始值为1
semaphore rCountMutex; // 控制对Rcount 的互斥修改,初始值为1
int rCount = 0; // 正在进行读操作的读者个数,初始化为0

// 写者进程/线程执行的函数
void writer()
{
while(TRUE)
{
P(wMutex); // 进入临界区
write();
V(wMutex); // 离开临界区
}
}

// 读者进程/线程执行的函数
void reader()
{
while(TRUE)
{
P(rCountMutex); // 进入临界区
if(rCount==0)
{
P(wMutex); // 如果有写者,则阻塞写者
}
rCount++; // 读者计数+1
V(rCountMutex); // 离开临界区

read(); // 读数据

P(rCountMutex); // 进入临界区
rCount--; // 读完数据,准备离开
if (rCount == 0)
{
V(wMutex); // 最后一个读者离开了,则唤醒写者
}
V(rCountMutex); // 离开临界区
}
}

上⾯的这种实现,是读者优先的策略,因为只要有读者正在读的状态,后来的读者都可以直接进⼊,如果读者持续不断进⼊,则写者会处于饥饿状态。

方案二:那既然有读者优先策略,⾃然也有写者优先策略:

  • 只要有写者准备要写⼊,写者应尽快执⾏写操作,后来的读者就必须阻塞;

  • 如果有写者持续不断写⼊,则读者就处于饥饿;

在⽅案⼀的基础上新增如下变量:

  • 信号量 rMutex :控制读者进⼊的互斥信号量,初始值为 1;

  • 信号量 wDataMutex :控制写者写操作的互斥信号量,初始值为 1;

  • 写者计数 wCount :记录写者数量,初始值为 0;

  • 信号量 wCountMutex :控制 wCount 互斥修改,初始值为 1;

具体实现如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
semaphore rCountMutex;  // 控制对Rcount 的互斥修改,初始值为1
semaphore rMutex; // 控制读者进入的互斥信号量者,初始值为1

semaphore wCountMutex // 控制wCount 互斥修改,初始值为1
semaphore wDataMutex // 控制写者写操作的互斥信号量,初始值为1

int rCount = 0 // 正在进行读操作的读者个数,初始化为0
int wCount = 0 // 正在进行读操作的写者个数, 初始化为0

// 写者进程/线程执行的函数
void writer()
{
while(TRUE)
{
P(wCountMutex); // 进入临界区
if (wCount == 0)
{
P(rMutex); // 当第一个写者进入,如果有读者则阻塞读者
}
wCount++; // 写者计数 + 1
V(wCountMutex); // 离开临界区

P(wDataMutex); // 写者写操作之间互斥,进入临界区
write(); // 写数据
V(wDataMutex); // 离开临界区

P(wCountMutex); // 进入临界区
wCount--; // 写完数据,准备离开
if (wCount == 0)
{
V(rMutex); // 最后一个写者离开了,则唤醒读者
}
V(wCountMutex); // 离开临界区
}
}

// 读者进程/线程执行的函数
void reader()
{
while(TRUE)
{
P(rMutex);
P(rCountMutex); // 进入临界区
if (rCount == 0)
{
P(wDataMutex); // 当第一个读者进入,如果有写者则阻塞写者写操作
}
rCount++;
V(rCountMutex); // 离开临界区
V(rMutex);

read(); //读数据

P(rCountMutex); // 进入临界区
rCount--;
f(rCount==0)
{
V(wDataMutex); // 当没有读者了,则唤醒阻塞中写者的写操作
}
V(rCountMutex); //离开临界区
}
}

注意,这⾥ rMutex 的作⽤,开始有多个读者读数据,它们全部进⼊读者队列,此时来了⼀个写者,执⾏了 P(rMutex) 之后,后续的读者由于阻塞在 rMutex 上,都不能再进⼊读者队列,⽽写者到来,则可以全部进⼊写者队列,因此保证了写者优先。同时,第⼀个写者执⾏了 P(rMutex) 之后,也不能⻢上开始写,必须等到所有进⼊读者队列的读者都执⾏完读操作,通过 V(wDataMutex) 唤醒写者的写操作。

方案三:既然读者优先策略和写者优先策略都会造成饥饿的现象,那么我们就来实现⼀下公平策略。

公平策略:

  • 优先级相同;

  • 写者、读者互斥访问;

  • 只能⼀个写者访问临界区;

  • 可以有多个读者同时访问临界资源;

具体代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
semaphore rCountMutex;  // 控制对Rcount 的互斥修改,初始值为1
semaphore wDataMutex; // 控制写者写操作的互斥信号量,初始值为1
semaphore flag; // 用于实现公平竞争,初始值为1
int rCount = 0; // 正在进行读操作的读者个数,初始化为0

// 写者进程/线程执行的函数
void writer()
{
while( TRUE)
{
P(flag);
P(wDataMutex); // 写者写操作之间互斥,进入临界区
write(); // 写数据
V(wDataMutex); // 离开临界区
V(flag);
}
}

// 读者进程/线程执行的函数
void reader()
{
while(TRUE)
{
P(flag);
P(rCountMutex); // 进入临界区
if (rCount == 0)
{
P(wDataMutex); // 当第一个读者进入,如果有写者则阻塞写者写操作
}
rCount++;
V(rCountMutex); // 离开临界区
V(flag);

read(); // 读数据

P(rCountMutex); // 进入临界区
rCount--;
if(rCount==0)
{
V(wDataMutex); // 当没有读者了,则唤醒阻塞中写者的写操作
}
V(rCountMutex); // 离开临界区
}
}

看完代码不知你是否有这样的疑问,为什么加了⼀个信号量 flag ,就实现了公平竞争?

对⽐⽅案⼀的读者优先策略,可以发现,读者优先中只要后续有读者到达,读者就可以进⼊读者队列,⽽写者必须等待,直到没有读者到达。没有读者到达会导致读者队列为空,即 rCount==0 ,此时写者才可以进⼊临界区执⾏写操作。⽽这⾥ flag 的作⽤就是阻⽌读者的这种特殊权限(特殊权限是只要读者到达,就可以进⼊读者队列)。⽐如:开始来了⼀些读者读数据,它们全部进⼊读者队列,此时来了⼀个写者,执⾏ P(falg) 操作,使得后续到来的读者都阻塞在 flag 上,不能进⼊读者队列,这会使得读者队列逐渐为空,即 rCount 减为 0。这个写者也不能⽴⻢开始写(因为此时读者队列不为空),会阻塞在信号量 wDataMutex 上,读者队列中的读者全部读取结束后,最后⼀个读者进程执⾏ V(wDataMutex) ,唤醒刚才的写者,写者则继续开始进⾏写操作。

进程间有哪些通信方式?

  • 管道:管道用于两个相关进程之间的通信,这是一种半双工的通信方式,如果需要全双工,需要另外一个管道。

  • 消息队列:消息队列是内核中存储消息的链表,它由消息队列标识符进行标识,这种方式能够在不同的进程之间提供全双工的通信连接。

  • 共享内存:共享内存是使用所有进程之间的内存来建立连接,这种类型需要同步进程访问来相互保护。

  • 信号量:信号量其实是一个整型的计数器,主要用于实现进程/线程间的互斥和同步,而不是用于缓存进程间通信的数据。

  • 信号:上面说的进程间的通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用 信号 的方式来通知进程。在Linux操作系统中,可以使用 kill -l 命令,来查看所有的命令。

  • Socket:前面提到的 管道、消息队列、共享内存、信号量、信号 都是在同一台主机上进行进程通信,要想跨网络与不同主机上的进程通信,就需要 socket 通信了。

  • 消息传递:消息传递是进程间实现通信和同步等待的机制,使用消息传递,进程间的交流不需要共享变量,直接就可以进行通信;消息传递分为发送方和接收方。

  • 先进先出队列:先进先出队列指的是两个不相关联进程间的通信,两个进程之间可以相互通信,这是一种全双工通信方式。

协程和多线程的区别

  • 开销。协程执行效率高,协程是单个线程执行,以子程序中断的形式切换,没有多线程切换的开销。

  • 协程不需要多线程的锁机制。不存在同时写变量冲突。