二十三. 进程同步与进程间通信

进程同步

看一下上一节写的调用外部shell的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
memset(final_path, 0, sizeof(final_path));

int32_t pid = fork();
if (pid)
{
while(1);
}
else
{
make_clear_abs_path(argv[0], final_path);
argv[0] = final_path;

struct stat file_stat;
memset(&file_stat, 0, sizeof(struct stat));
if (stat(argv[0], &file_stat) != -1)
{
execv(argv[0], argv);
}
while(1);
}

首先是shell fork出一个子进程,通过该子进程调用exec将当前进程替换为要执行的外部命令。此时在父进程的执行代码处,我们用了一个死循环将其简单的阻塞在此,那么为什么这里父进程要阻塞自己。

在fork的原理说过,fork出来的子进程会完全复制父进程的所有资源,包括父进程的堆栈环境等等。在这一段代码中,fork出的子进程会使用到复制过来的栈中的数据,如final_path。如果fork还没有执行完,也就是说子进程还没有完全复制父进程的堆栈环境,而父进程在此时改变了它栈中的数据,那么此时子进程复制的数据可能就不是它想要的数据。此时便需要父进程阻塞起来,让子进程先执行,这就是需要进行进程同步的地方。

wait和exit

这一节将会通过wait和exit来实现进程的同步,首先来理解一下wait和exit是来干什么的。

wait的作用是让一个进程阻塞自己,直到他的某一个子进程退出。所以说,wait通常由父进程来调用。当一个进程调用wait的时候,内核便会去查找该进程的子进程,如果没有子进程,此时wait会返回-1,否则,该进程便会被阻塞,此时内核就会去遍历它的所有子进程,查找是否有子进程退出,如果有子进程退出便将该子进程的返回值传递给父进程,随后将父进程唤醒。

exit的作用就如其名字一样,让进程退出,结束执行。

孤儿进程和僵尸进程

在wait和exit的调用中会产生两个非常有意思的概念,孤儿进程和僵尸进程。

前面介绍wait的时候也说了,wait的一个作用是阻塞父进程自己,使父子进程同步,它的另一个作用是获取子进程的退出状态,也就是取得子进程的退出时的返回值。这个返回值就是平常在main函数中写的return 0,这个0就代表这个进程的退出状态。

当子进程执行完main函数之后,程序的执行流程会返回到c运行库之中(这个是之前所介绍的,main函数只是程序执行流程中的中间部分,它是由c运行库调用通过call指令调用的,执行完了之后会返回)。c运行库会把进程return的返回值通过系统调用exit提交给内核。

下面来解释一下什么是孤儿进程和僵尸进程

如果一个子进程运行结束了,它的父进程没有调用wait,这个子进程就变成了僵尸进程。

如果子进程还在运行,而他的父进程已经退出了,这个子进程就变了孤儿进程。

上面是僵尸进程和孤儿进程的基本概念,接下来从内核层理解一下为什么会出现僵尸进程和孤儿进程。

在进程退出的时候,无论如果他都会调用exit,不管是这个进程主动调用exit退出,还是执行完了之后进入c运行库调用exit。exit会得到一个退出状态。

1
void exit(int status);

这是exit的函数原型,status就是传递进去的退出状态,那么这个退出状态要存储在什么地方呢。又如何将这个退出状态传给父进程呢。

由于进程都是自己独立的地址空间,即使是父子之前,他们也是相互独立不可互相访问的,进程间想要通信必须要借助内核,子进程的返回值肯定要先提交给内核,然后父进程向内核要子进程的返回值,父进程如何向内核要子进程的返回值呢,看一下wait函数的原型就知道了

1
pid_t wait(int *status);

status是父进程用来存储子进程返回值的地址,父进程调用它后,内核就会它子进程的返回值存储到status指向的内存空间中。

接下来说一下子进程的返回值到底存储在什么地方。在操作系统中,为了方便管理,进程相关的数据都统一放在pcb中,当进程结束时,它的返回值会被内核放到进程的pcb中。由于此时进程已经执行完了,内核会把进程占用的大部分资源回收,比如内存,页表等,但是进程的pcb所占的内存还不能回收,因为里面存储着进程的返回值,就像是临终遗言一样,还没有交出去,需要交付给父进程之后才能被回收。它应该在父进程调用wait获取子进程的返回值后,再由内核回收子进程pcb所占用的页框。

说到这里,相信僵尸进程就很好理解了,因为子进程结束了,父进程没有调用wait,导致子进程的pcb无法被内核回收,而导致内核空间被浪费。如果系统中存在大量的僵尸进程,还可能导致无法启动新的进程,毕竟内核的空间总是有限的。

孤儿进程相对来说好理解一点,父进程退出了,子进程还没有调用exit,此时子进程便成了孤儿进程。因为没有父进程再来获取该子进程的退出状态了嘛。但是在linux的处理策略中,孤儿进程会被init进程收养,也就是说,init进程会成为这些进程的父进程,子进程退出的时候就有init进程来为其”收尸”罗。这其实是一种顺理成章的处理策略,因为init进程本身就是所有进程的父进程。

概念上差不多就这些,接下来看一下实现

wait

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
/* 等待子进程调用exit,将子进程的退出状态保存到status指向的变量.
* 成功则返回子进程的pid,失败则返回-1 */
pid_t sys_wait(int32_t *status)
{
task_struct *parent_thread = running_thread();

while (1)
{
/* 优先处理已经是挂起状态的任务 */
struct list_elem *child_elem = list_traversal(&thread_all_list, find_hanging_child, parent_thread->pid);
/* 若有挂起的子进程 */
if (child_elem != NULL)
{
task_struct *child_thread = elem2entry(task_struct, all_list_tag, child_elem);
*status = child_thread->exit_status;

/* thread_exit之后,pcb会被回收,因此提前获取pid */
uint16_t child_pid = child_thread->pid;

/* 2 从就绪队列和全部队列中删除进程表项*/
thread_exit(child_thread, false); // 传入false,使thread_exit调用后回到此处
/* 进程表项是进程或线程的最后保留的资源, 至此该进程彻底消失了 */

return child_pid;
}

/* 判断是否有子进程 */
child_elem = list_traversal(&thread_all_list, find_child, parent_thread->pid);
if (child_elem == NULL)
{ // 若没有子进程则出错返回
return -1;
}
else
{
/* 若子进程还未运行完,即还未调用exit,则将自己挂起,直到子进程在执行exit时将自己唤醒 */
thread_block(TASK_WAITING);
}
}
}

exit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 子进程用来结束自己时调用 */
void sys_exit(int32_t status)
{
task_struct *child_thread = running_thread();
child_thread->exit_status = status;

/* 将进程child_thread的所有子进程都过继给init */
list_traversal(&thread_all_list, init_adopt_a_child, child_thread->pid);

/* 回收进程child_thread的资源 */
release_prog_resource(child_thread);

/* 如果父进程正在等待子进程退出,将父进程唤醒 */
task_struct *parent_thread = pid2thread(child_thread->parent_pid);
if (parent_thread->status == TASK_WAITING)
{
thread_unblock(parent_thread);
}

/* 将自己挂起,等待父进程获取其status,并回收其pcb */
thread_block(TASK_HANGING);
}

管道通信

管道的原理

进程虽然是独立运行的个体,但他们之间有时候也需要协作才能完成一些工作,比如有两个进程需要同步数据,进程A把数据准备好后,想把数据发往进程B,进程B必须被提前通知有数据到来。这些需求很多,所以操作系统必须要实现进程间的相互通信。

进程间通信的方式有很多种,有消息队列,共享内存,socket通信,管道等。在这里kernel中就打算实现管道。而且管道命令在linux的shell中使用可以说是非常频繁的。

管道是文件的一种,(操作系统上文件是一个非常大的概念,像管道,socket,设备等都归属于文件的概念中)只是该文件并不存在与文件系统上,它只存在于内存中。既然管道是属于文件的一种,就要按照文件操作的方式来使用,通过open,close,read,write来使用管道,这应该是约定的一种规范吧。

管道是数据的一个中转站,当某个进程往管道中写入数据后,该数据就会被另一个进程读取,之后用新的数据覆盖旧数据,既然是一块数据缓存区,就应该有一个大小。但是由于写入的数据大小是不确定的,这块缓存区的大小很难确定下来,一般来说会使用环形缓存区来存储数据,通过生产者消费者模型对这块环形缓冲区的数据进行读写。这个环形缓冲区用两个指针来维护,一个专门负责读,一个专门负责写,当缓冲区数据满时,生产者睡眠并唤醒消费者。缓冲区空时,消费者睡眠,唤醒生产者。

管道有两端,一端用来读,一端用来写。这个两端的概念实质上是内核为一个管道分配了两个文件描述符,一个负责写,一个负责读。它的模型如下图

当然,管道创建出来后,自己写自己读是没有意义的。所以通常的走法是创建一个管道后,fork一个子进程,这个子进程会继承当前进程的所有资源,当然也就包括他打开的管道啦。所以父子进程都能通过管道描述符向管道中读写数据。

管道分为两种,匿名管道和命名管道。匿名管道是创建之后只能通过文件描述符来访问的,此管道只对创建它的进程和其子进程可见,其他进程是访问不到的。命名管道就是可以通过其名称,找到该管道的文件描述符,对所有进程都可见。

linux中管道的设计

linux支持的文件系统比较多,包括ext2,ext3,ext4,ntfs等。为了提供统一的接口,linux加了一层中间层VFS(虚拟文件系统)。Linux处理管道时是利用现有的文件结构和VFS中inode共同完成的,并没有为管道提供另外的数据结构。如下图所示

在图中的文件结构中,f_inode执行VFS中的inode,该inode指向1一个页框大小的内存区域,也就是说linux中管道的缓冲区大小为4096byte,f_op指向操作方法,对于不同的操作对象提供不同的操作方法,对于管道来说,f_op会指向pipe_read或pipe_write方法。

管道的实现

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
/* 判断文件描述符local_fd是否是管道 */
bool is_pipe(uint32_t local_fd)
{
uint32_t global_fd = fd_local2global(local_fd);
return file_table[global_fd].fd_flag == PIPE_FLAG;
}

/* 创建管道,成功返回0,失败返回-1 */
int32_t sys_pipe(int32_t pipefd[2])
{
int32_t global_fd = get_free_slot_in_global();

/* 申请一页内核内存做环形缓冲区 */
file_table[global_fd].fd_inode = get_kernel_pages(1);

/* 初始化环形缓冲区 */
ioqueue_init((struct ioqueue *)file_table[global_fd].fd_inode);
if (file_table[global_fd].fd_inode == NULL)
{
return -1;
}

/* 将fd_flag复用为管道标志 */
file_table[global_fd].fd_flag = PIPE_FLAG;

/* 将fd_pos复用为管道打开数 */
file_table[global_fd].fd_pos = 2;
pipefd[0] = pcb_fd_install(global_fd);
pipefd[1] = pcb_fd_install(global_fd);
return 0;
}

从管道中读数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 从管道中读数据 */
uint32_t pipe_read(int32_t fd, void *buf, uint32_t count)
{
char *buffer = buf;
uint32_t bytes_read = 0;
uint32_t global_fd = fd_local2global(fd);

/* 获取管道的环形缓冲区 */
struct ioqueue *ioq = (struct ioqueue *)file_table[global_fd].fd_inode;

/* 选择较小的数据读取量,避免阻塞 */
uint32_t ioq_len = ioq_length(ioq);
uint32_t size = ioq_len > count ? count : ioq_len;
while (bytes_read < size)
{
*buffer = ioq_getchar(ioq);
++bytes_read;
++buffer++;
}
return bytes_read;
}

往管道写数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 往管道中写数据 */
uint32_t pipe_write(int32_t fd, const void *buf, uint32_t count)
{
uint32_t bytes_write = 0;
uint32_t global_fd = fd_local2global(fd);
struct ioqueue *ioq = (struct ioqueue *)file_table[global_fd].fd_inode;

/* 选择较小的数据写入量,避免阻塞 */
uint32_t ioq_left = bufsize - ioq_length(ioq);
uint32_t size = ioq_left > count ? count : ioq_left;

const char *buffer = buf;
while (bytes_write < size)
{
ioq_putchar(ioq, *buffer);
bytes_write++;
buffer++;
}
return bytes_write;
}

在shell中支持管道

在平常使用shell的使用,经常会使用到管道命令,类似这种。

1
ps -ef | grep xxx | grep -v grep

管道之所以可以这样使用,是进行了输入输出重定向。通常情况下键盘是输入,屏幕是输入。这就是标准输入与标准输出。而输入输出重定向就是改变输入输出的位置,比如从文件中读取输入称为输入重定向,将结果输出到文件中称为输出重定向。

而管道的作用就是利用了输入输出重定向的与原理,将一个命令的输出作为另一个命令的输入来使用。管道符左边命令的输出数据会作为右边命令的输入数据使用。

核心原理就是这样,实现的时候就需要把旧的文件描述符替换为新的文件描述符,文件描述符是我们获取数据和写入数据的根本,改变了文件描述符中的数据,输入输出的位置自然也就变了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 将文件描述符old_local_fd重定向为new_local_fd */
void sys_fd_redirect(uint32_t old_local_fd, uint32_t new_local_fd)
{
task_struct *cur = running_thread();
/* 恢复标准描述符 */
if (new_local_fd < 3)
{
cur->fd_table[old_local_fd] = new_local_fd;
}
else
{
uint32_t new_global_fd = cur->fd_table[new_local_fd];
cur->fd_table[old_local_fd] = new_global_fd;
}
}

接下来看一下shell中对管道的处理。

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
/* 针对管道的处理 */
char *pipe_symbol = strchr(cmd_line, '|');
if (pipe_symbol)
{
/* 支持多重管道操作,如cmd1|cmd2|..|cmdn,
* cmd1的标准输出和cmdn的标准输入需要单独处理 */

/*1 生成管道*/
int32_t fd[2] = {-1}; // fd[0]用于输入,fd[1]用于输出
pipe(fd);
/* 将标准输出重定向到fd[1],使后面的输出信息重定向到内核环形缓冲区 */
fd_redirect(stdout_no, fd[1]);

/*2 第一个命令 */
char *each_cmd = cmd_line;
pipe_symbol = strchr(each_cmd, '|');
*pipe_symbol = 0;

/* 执行第一个命令,命令的输出会写入环形缓冲区 */
argc = -1;
argc = cmd_parse(each_cmd, argv, ' ');
cmd_execute(argc, argv);

/* 跨过'|',处理下一个命令 */
each_cmd = pipe_symbol + 1;

/* 将标准输入重定向到fd[0],使之指向内核环形缓冲区*/
fd_redirect(stdin_no, fd[0]);
/*3 中间的命令,命令的输入和输出都是指向环形缓冲区 */
while ((pipe_symbol = strchr(each_cmd, '|')))
{
*pipe_symbol = 0;
argc = -1;
argc = cmd_parse(each_cmd, argv, ' ');
cmd_execute(argc, argv);
each_cmd = pipe_symbol + 1;
}

/*4 处理管道中最后一个命令 */
/* 将标准输出恢复屏幕 */
fd_redirect(stdout_no, stdout_no);

/* 执行最后一个命令 */
argc = -1;
argc = cmd_parse(each_cmd, argv, ' ');
cmd_execute(argc, argv);

/*5 将标准输入恢复为键盘 */
fd_redirect(stdin_no, stdin_no);

/*6 关闭管道 */
close(fd[0]);
close(fd[1]);
}