Linux 系统编程

第五章 进程

5.1 进程的基本概念

一、程序与进程

  • 程序是存放在磁盘文件中的可执行文件,本质上是代码集合。

  • 程序的执行实例被称为进程

    • 进程有独立的权限与职责,如果系统中的某个进程崩溃,不会影响到其余的进程。
    • 每个进程运行在各自的虚拟地址空间中,进程之间可以通过由内核控制的机制相互通讯
  • 每个 Linux 进程都有一个唯一的数字标识符(非负整数),称为进程 ID(process ID)。

    查看进程 ID

    1
    ps -ef | more

二、进程在内核中的结构

进程表项 / 进程控制块task_struct

截屏2022-12-31 16.01.27

三、进程的启动与终止

启动例程

  • 在进程的main函数执行之前内核会启动特殊的启动例程
  • 编译器在编译时会将启动例程编译进可执行文件
  • 启动例程的作用
    • 搜集命令行参数传递给main函数的argcargv
    • 搜集环境信息构建环境表并传递给main函数
    • 登记进程的终止函数

进程终止

  • 正常终止
    • main函数返回
    • 调用exit函数(标准 C 库<stdlib.h>
    • 调用_exit_Exit函数(系统调用)
    • 最后一个线程从其启动例程返回
    • 最后一个线程调用pthread_exit
  • 异常终止
    • 调用abort
    • 接收到一个信号并终止
    • 最后一个线程对取消请求做处理响应

进程返回

  • 通常,程序运行成功返回 0,否则返回非 0。
  • 在 Shell 中可以查看进程返回值echo $?
1
2
3
4
5
6
7
8
9
// 登记进程的终止函数
#include <stdlib.h>
int atexit(void (*function)(void)); // 成功返回 0, 出错返回 -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
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

void fun1() { printf("term fun1\n"); }
void fun2() { printf("term fun2\n"); }
void fun3() { printf("term fun3\n"); }

int main(int argc, char *argv[])
{
if (argc < 2) {
fprintf(stderr, "-usage: %s [return|exit|_exit]\n", argv[0]);
exit(1);
}
atexit(fun1); atexit(fun2); atexit(fun3);
FILE *fp = fopen("./hello.txt", "w");
fprintf(fp, "hello");
if (!strcmp(argv[1], "return")) return 0;
else if (!strcmp(argv[1], "exit")) exit(0);
else if (!strcmp(argv[1], "_exit")) _exit(0);
else {
fprintf(stderr, "usage: %s file return | exit | _exit\n", argv[0]);
exit(1);
}
}
  • 调用returnexit时,按照fun3fun2fun1顺序执行进程终止函数,并且成功创建文件并写入内容。
  • 调用_exit时,用户登记的终止函数将不被执行,同时由于标准 I/O 采用全缓存,而_exit不会主动清缓存,所以文件仅被创建,而未被写入内容。

截屏2022-12-31 17.20.18

四、进程的属性和状态

通过ps -auxps -ef等命令查看进程的详细信息。

常见的信息有

缩写 信息
USER / UID 进程的属主(ID)
PID 进程的编号
PPID 父进程的编号
%CPU 进程占用的 CPU 百分比
%MEM 进程占用的内存百分比
VSZ 进程使用虚拟内存的大小
TTY 终端的 ID
START 启动进程的时间
TIME 进程消耗 CPU 的时间
COMMAND 命令的名称和参数

其中 STAT 为进程的状态

截屏2023-01-01 17.49.51

5.2 进程编程

一、进程属性的获取

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>
#include <sys/types.h>
pid_t getpid(); /* 当前进程的 ID */
pid_t getppid(); /* 当前进程的父进程 ID */
pid_t getpgrp(); /* 当前进程所在进程组的 ID */
pid_t getpgid(pid_t pid); /* 进程 pid 所在进程组的 ID */

uid_t getuid(); /* 当前进程的实际用户 ID */
uid_t geteuid(); /* 当前进程的有效用户 ID */
gid_t getgid(); /* 当前进程的用户组 ID */

关于实际用户和有效用户的说明

使用账号密码登陆 Linux 系统运行程序,此即实际用户。对于一个普通的可执行程序a.out,使用命令sudo chown root.root a.out将其拥有者改为超级用户root,并增加黏着位sudo u+s a.out,此时运行./a.out,实际用户不变,而有效用户变为root黏着位的作用即是赋予普通用户更高的权限

二、创建进程

1
2
3
4
5
6
#include <unistd.h>
#include <sys/types.h>
pid_t fork();
// 子进程中返回 0,父进程中返回子进程 ID;出错返回 -1
pid_t vfork();
// 子进程中返回 0,父进程中返回子进程 ID;出错返回 -1。保证子进程先运行,且不拷贝父进程内存

fork创建的新进程被称为子进程,该函数被调用一次,但返回两次。在子进程中返回值为 0,在父进程中返回的是子进程的 ID。

示例:fork 出子进程,并在父子进程中各自打印信息

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
pid_t pid = fork();
if (pid < 0) {
perror("fork error");
exit(1);
} else if (pid > 0) {
//----- 这是父进程将执行的代码 -----
printf("I'm parent. pid = %d, ppid = %d, fork() return: %d\n",
getpid(), getppid(), pid);
//-------------------------------
} else {
//----- 这是子进程将执行的代码 -----
printf("I'm child. pid = %d, ppid = %d, fork() return: %d\n"
getpid(), getppid(), pid););
//-------------------------------
}
//----- 这是父子进程都将执行的代码 -----
printf("pid = %d\n", getpid());
sleep(1);
return 0;
//----------------------------------
}

注意:fork后父子进程谁先运行不确定,根据系统调度决定。

fork成功的时候,子进程被创建,并将复制父进程的内存空间,父子进程都将从变量pid接收到fork的返回值那一步开始,继续往后运行。具体的复制情况如下

截屏2023-01-01 17.48.57

  • 子进程继承来的属性

    用户信息和权限、目录信息、信号信息、环境、共享存储段、资源限制、堆、栈、全局数据段、共享代码段

  • 子进程特有的属性

    进程 ID、锁信息、运行时间、未决信号

  • 操作文件时的内核结构变化

    • 子进程只继承父进程的文件描述符表,不继承但共享文件表项和 i 节点
    • 父进程创建子进程后,文件表项中的引用计数器 +1,当父进程关闭文件后,计数器 -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
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_v = 0;

int main()
{
int l_v = 0;
static int s_v = 0;
int *h_v = (int *)malloc(5 * sizeof(int));

pid_t pid;

if ((pid = fork()) < 0) {
perror("fork error"); exit(1);
} else if (pid > 0) {
printf("[parent] g_v@%p, l_v@%p, s_v@%p, h_v@%p\n", &g_v, &l_v, &s_v, &h_v);
g_v = 1; l_v = 2; s_v = 3;
for (int i = 0; i < 5; i++) h_v[i] = i;
} else {
printf("[child] g_v@%p, l_v@%p, s_v@%p, h_v@%p\n", &g_v, &l_v, &s_v, &h_v);
g_v = 10; l_v = 20; s_v = 30;
for (int i = 0; i < 5; i++) h_v[i] = i * 10;
}

printf("g_v = %d, l_v = %d, s_v = %d, array h_v: ", g_v, l_v, s_v);
for (int i = 0; i < 5; i++) printf("%d ", h_v[i]);
printf("\n");

sleep(1);
return 0;
}

fork 出子进程后,子进程也会拥有父进程的文件描述符和文件指针,可以对该文件进行操作。父子进程结束前,应各自关闭资源。标准 I/O 是有缓存的,该缓冲区位于堆上,如果在 fork 之前父进程用标准 I/O 写了一些内容而没有 flush,则子进程会把堆上缓冲区里的内容复制过来,从而一共输出两次。

演示:两类 I/O 方式的区别

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
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>

int main()
{
FILE *fp = fopen("libIO.txt", "w");
int fd = open("sysIO.txt", O_WRONLY | O_CREAT | O_TRUNC, 0660);

char *s = "This is a test."; // 父进程实现写一些内容
fprintf(fp, "%s", s); // 用标准 I/O 实现,实际存进了堆上的缓冲区里
ssize_t size_s = strlen(s);
write(fd, s, size_s); // 用系统调用实现,直接写入文件

pid_t pid;
if ((pid = fork()) < 0) {
perror("fork error"); exit(1);
} else if (pid > 0) {
char *f = "\nparent\n";
fprintf(fp, "%s", f);
ssize_t size_f = strlen(f);
write(fd, f, size_f);
fclose(fp); // 父进程关闭资源,This is a test.[/n]parent[/n] 将被写入文件
close(fd);
} else {
char *c = "\nchild\n";
fprintf(fp, "%s", c);
ssize_t size_c = strlen(c);
write(fd, c, size_c);
fclose(fp); // 子进程关闭资源,This is a test.[/n]child[/n] 将被写入文件
close(fd);
}

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
# libIO.txt
This is a test.
parent
This is a test.
child

# sysIO.txt
This is a test.
parent

child

fork 子进程时父子进程共享全部代码,则连续 fork 会导致进程数量指数级别增加。一般通过带有条件控制的循环构造进程链或进程扇。

截屏2023-01-01 20.38.00

构造有 $n$ 个进程的进程链

1
2
3
4
5
6
7
8
9
10
pid_t pid;
for (int i = 1; i < n; i++) { // 最开始已经有一个进程了
pid = fork();
if (pid < 0) {
perror("fork error");
exit(1);
} else if (pid > 0) {
break; // 父进程跳出循环,子进程继续循环 fork
}
}

构造有 $n$ 个进程的进程扇

1
2
3
4
5
6
7
8
9
10
pid_t pid;
for (int i = 1; i < n; i++) { // 最开始已经有一个进程了
pid = fork();
if (pid < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
break; // 子进程跳出循环,父进程继续 fork
}
}

三、三类特殊的进程

守护进程(daemon)

  • 守护进程是生存周期很长的一种进程,通常在系统引导装入时启动,在系统关闭时终止。
  • 所有守护进程都以超级用户优先权运行。
  • 守护进程不控制终端
  • 守护进程的父进程都是 init 进程。

孤儿进程

  • 父进程结束,子进程就称为孤儿进程,会被 1 号进程 init 进程领养

    示例:孤儿进程的领养者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>

    int main()
    {
    pid_t pid;
    if ((pid = fork()) < 0) {
    perror("fork error"); exit(1);
    } else if (pid > 0) {
    printf("%d died.\n", getpid());
    exit(0);
    } else {
    sleep(4); // 睡眠 4 秒,保证在此期间父进程已经结束
    printf("pid: %d, ppid: %d", getpid(), getppid());
    }

    return 0;
    }

僵尸进程

  • 子进程结束但是没有释放内存中的进程表项,则称为僵尸进程。

  • 僵尸进程不会占用很多系统资源,但会一直占有一个进程表项,而系统可供使用的进程表项数量是有限的。

  • 避免僵尸进程的方法

    • 直接杀死该进程
    • 结束父进程,让僵尸进程变成孤儿进程,由 1 号进程回收释放
    • 通过waitwaitpid系统调用,让父进程轮询子进程是否结束,结束则通知内核回收
    • 采用信号SIGCHLD通知父进程,并在信号处理函数中调用wait

    制造一个僵尸进程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>

    int main()
    {
    pid_t pid;
    if ((pid = fork()) < 0) {
    perror("fork error");
    exit(1);
    } else if (pid == 0) {
    printf("pid: %d, ppid: %d\n", getpid(), getppid());
    exit(0); // 子进程结束,本该由父进程通知内核回收
    }
    while (1) sleep(1); // 父进程一直睡眠,子进程成为僵尸进程
    return 0;
    }

四、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
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
// 成功返回子进程 ID,出错返回 -1
/*
在一个子进程终止前,wait 使父进程阻塞。待子进程终止后,其父进程将通知内核回收
父进程调用一次 wait 只能保证一个子进程被回收。如有多个子进程应多次 wait
参数 status 接收子进程的返回状态,通过三组宏判断与解析:
- WIFEXITED(status) / WEXITSTATUS(status)
判断是否为正常终止 / 如果是,解析返回值
- WIFSIGNALED(status) / WTERMSIG(status)
判断是否为异常终止 / 如果是,解析错误信号
- WIFSTOPPED(status) / WSTOPSIG(status)
判断终止前是否暂停过 / 如果是,解析暂停信号

waitpid 可通过参数 pid 指定等待某个子进程结束,此时父进程仍是阻塞型等待
参数 options 可以指定特殊的等待方式
- WNOHANG
若子进程尚未终止,则立即返回而不阻塞,此时【返回值为 0】
- WUNTRACED
若子进程已经暂停,且其状态自暂停以来还未报告过,则返回其状态
- 两个参数通过按位或方式一起使用
- 如欲解析 WSTOPSIG,则必须通过此函数等待子进程结束,并指定 WUNTRACED 模式,详见下例

waitpid 的参数 pid 的不同取值
- pid = -1 等待任一子进程,功能与 wait 等效
- pid > 0 等待 ID 为 pid 的子进程
- pid = 0 等待同进程组中的任一子进程
- pid < -1 等待组 ID 为 |pid| 的任一子进程
*/

示例: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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

void out_status(int status) // 解析状态码
{
printf ("status: %d\n", status); // 输出原始状态码
if (WIFEXITED(status)) { // 正常退出
printf("normal exit. return value: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) { // 异常终止
printf("abnormal terminate. sig: %d\n", WTERMSIG(status));
} else if (WIFSTOPPED(status)) { // 暂停
printf("stopped. stop sig: %d\n", WSTOPSIG(status));
} else {
printf("unknown type\n");
}
}

int main()
{
pid_t pid;
int status;

// 正常退出
if ((pid = fork()) < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
exit(5);
}
wait(&status);
out_status(status);

// 访问空指针,异常终止
if ((pid = fork()) < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
int *p = NULL;
*p = 10;
}
wait(&status);
out_status(status);

// 强行 abort,异常终止
if ((pid = fork()) < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
abort();
}
wait(&status);
out_status(status);

// 子进程暂停
if ((pid = fork()) < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
printf("pid: %d\n", getpid()); // 输出 pid,从而发信号 kill -SIGSTOP {pid}
// 两类暂停方式皆可
// pause();
while (1) {
sleep(1);
}
}

int ret = 0; // 接收 waitpid 返回值
do {
ret = waitpid(pid, &status, WNOHANG | WUNTRACED); // 非阻塞且查看是否暂停
if (ret == 0) sleep(1); // 若返回值为 0,表示子进程尚未终止
} while (ret == 0);
out_status(status);

return 0;
}

五、execsystem系统调用

exec是一族函数

  • 当进程调用了一种exec函数后,另一个新程序将替换当前进程的正文、数据和堆栈

  • 通过exec可以执行另一个程序,但一般通过 fork 出的子进程调用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <unistd.h>
    int execl(const char *pathname,
    const char *arg0, .../* (char *)0 */);
    int execv(const char *pathname,
    char *const argv[]);
    int execle(const char *pathname,
    const char *arg0, .../* (char *)0, char *const envp[] */);
    int execve(const char *pathname,
    char *const argv[],
    char *const envp[]);
    int execlp(const char *pathname,
    const char *arg0, .../* (char *)0 */);
    int execvp(const char *pathname,
    char *const argv[]);
    // 出错返回 -1,成功不返回

    execve函数为系统调用,其余均为库函数。

    六个函数的区别

    • 名字中含有 l 的:形参列表从第二个参数开始是要传递给新程序的参数,第一个参数必须是程序名,最后一个参数必须是NULL
    • 名字中含有 p 的:第一个形参可以是绝对路径,也可以是相对路径。如果是相对路径,必须包含于程序环境表中PATH指定的路径中。
    • 名字中含有 v 的:形参列表的第二个参数是一个字符串数组,第一个元素必须是程序名,最后一个元素必须是NULL
    • 名字中含有 e 的:用户可以传入自定义的环境表。

    截屏2023-01-02 15.06.59

system是简化版的命令执行函数

1
2
#include <stdlib.h>
int system(const char *cmd); // 成功返回执行命令的状态,出错返回 -1
  • system函数内部 fork 出一个子进程,由子进程调用exec函数。
  • 在终端上(如 bash)执行命令cmd,等价于执行bash -c cmd

示例:编写自己的system函数

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

const char *cmd1 = "date > date.txt";
const char *cmd2 = "ls -al > contents.txt";

void my_system(const char *cmd)
{
pid_t pid;
if ((pid = fork()) < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
if (execlp("/bin/bash", "bash", "-c", cmd, NULL) < 0) {
perror("execlp error");
exit(1);
}
}
wait(NULL);
}

int main()
{
system("clear");
my_system(cmd1);
my_system(cmd2);

return 0;
}

5.3 进程信号

一、信号的基本概念

  • 信号(signal)机制是 Linux 最古老的进程间通信机制,用于解决进程在正常运行时被中断的问题,使得进程的处理流程发生变化。

  • 信号是软件中断

  • 信号是异步事件

    • 具有不可预见性
    • 信号有自己的名称和编号
    • 可自定义信号的异常处理机制
  • 信号的来源

    • 硬件来源:键盘的按键或其他硬件故障,由硬件驱动程序产生。
    • 软件来源:常见的发送信号的函数有kill raise alarm setitimer等,此外软件来源还包括非法操作(如除 0)和软件设置条件(如 gdb 调试)等。信号由内核产生。
  • 信号无优先级。通过命令kill -l可查看所有信号。其中,编号 1~31 为非实时信号,发送的信号可能会丢失,且不支持信号排队;32~64 为实时信号,支持排队,发送的多个信号都一定会被接收。

一些常见的信号

编号 名称 功能 编号 名称 功能
1 SIGHUP 让进程挂起的信号 2 SIGINT 让进程中断的信号
3 SIGQUIT 让进程退出的信号 6 SIGABRT 让进程强行终止的信号
7 SIGBUS 总线错误信号 8 SIGFPE 浮点运算错误信号
9 SIGKILL 杀死进程的信号 10 SIGUSR1 用户可自定义信号
11 SIGSEGV 段错误信号 12 SIGUSR2 用户可自定义信号
13 SIGPIPE 管道信号 14 SIGALRM 定时器发出的信号
17 SIGCHLD 子进程状态变化的信号 18 SIGCONT 让进程继续运行的信号
19 SIGSTOP 让进程暂停运行的信号 20 SIGTSTP 通过键盘暂停程序的信号
  • control + Z:SIGTSTP
  • control + C:SIGINT
  • control + \:SIGQUIT

二、信号的处理方式

  • 忽略信号
    • SIGKILL 和 SIGSTOP 不能被忽略
    • 忽略硬件异常
    • SIGUSR1 和 SIGUSR2 默认情况下被忽略
  • 执行默认操作
    • 每个信号都有默认处理方法,大部分情况下是终止进程
  • 捕获信号
    • 告诉内核出现信号时调用的处理函数
    • SIGKILL 和 SIGSTOP 不能被捕获

三、signal系统调用

1
2
3
4
5
6
7
8
9
10
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
/*
返回值:若成功返回信号处理函数的指针,出错返回 SIG_ERR
功能:向内核登记信号处理函数

参数
- signo 要登记的信号(一般用宏表示)
- func 可以是信号处理函数指针 / SIG_IGN 忽略信号 / SIG_DFL 默认
*/

示例:signal的使用

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>

int cnt = 0;

void sig_handler(int sig)
{
printf("sig [%d] occurred\n", sig);
cnt++;
}

int main()
{
if (signal(SIGINT, sig_handler) == SIG_ERR) {
perror("signal SIGINT error");
}
if (signal(SIGTSTP, sig_handler) == SIG_ERR) {
perror("signal SIGTSTP error");
}
if (signal(SIGKILL, sig_handler) == SIG_ERR) { // 登记失败
perror("signal SIGKILL error");
}

while (cnt < 5) sleep(2);
return 0;
}

利用 SIGCHLD 信号回收子进程,避免僵尸进程

  • 子进程状态发生变化(暂停 / 继续运行 / 结束)时产生该信号,父进程收到信号调用信号处理函数并通知内核回收。
  • 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
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

void sig_handler(int sig)
{
printf("child exit. signal: %d\n", sig);
wait(NULL);
}

void out(int n, const char *s)
{
for (int i = 1; i <= n; i++) {
printf("%s: %d\n", s, i);
sleep(1);
}
}

int main()
{
pid_t pid;
if (signal(SIGCHLD, sig_handler) == SIG_ERR) {
perror("signal SIGCHLD error");
exit(1);
}
if ((pid = fork()) < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
out(10, "child");
} else {
out(20, "parent");
}
return 0;
}

四、信号发送

  • 除了内核和超级用户,并非所有进程都可以向其他进程发信号。
  • 一般的进程只能向具有相同 uid 和 gid 的进程发信号,或者向相同进程组的其他进程发信号。
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <signal.h>
int kill(pid_t pid, int signo);
// 功能:向指定进程发送信号。成功返回 0,出错返回 -1
int raise(int signo);
// 功能:向自己发送信号,相当于 kill(getpid(), signo)。成功返回 0,出错返回 -1

/*
kill 函数的 pid 参数
- pid > 0 将信号发送给 ID 为 pid 的进程
- pid = 0 将信号发送给同一进程组的所有进程
- pid < -1 将信号发送给组 ID 为 |pid| 的所有进程
- pid = -1 将信号发送给其有权发送信号的所有进程
*/

0 为空信号,通常用来检测特定的进程是否存在。

五、alarm系统调用

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
/*
返回值:0 或以前设置的定时器剩余的秒数
功能:设置一个定时器。超时后产生 SIGALRM 信号发送给进程本身

参数为 0 时表示取消之前的定时器。

alarm 不是周期性的,而是一次性的。
*/

示例:设置周期性定时器

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
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

void sig_handler(int sig)
{
if (sig == SIGALRM) {
printf("time out\n");
alarm(2); // 在信号处理后再次设置定时器,实现周期性计时
}
}

void out()
{
for (int i = 1; i <= 20; i++) {
printf("[pid]%d: %d\n", getpid(), i);
sleep(1);
}
}

int main()
{
if (signal(SIGALRM, sig_handler) == SIG_ERR) {
perror("signal SIGALRM error"); exit(1);
}
printf("--- clock start ---\n");
alarm(2); // 首次设置定时器
out();
printf("--- clock stop ---\n");

return 0;
}

六、信号集

信号在内核中的表示

  • 三个概念

    • 信号递达(delivery):执行信号处理的动作
    • 信号未决(pending):信号从产生到递达之间的状态
    • 信号阻塞(block):被阻塞的信号产生时将处于未决状态,直至进程解除对此信号的阻塞

    注意

    只要信号被阻塞就不会抵达,除非解除阻塞。而忽略是在信号递达之后的一种可选的处理动作。

  • 信号在进程表项中由三张表存储

    截屏2023-01-02 21.44.43

    • SIGHUP 信号不会阻塞,也未产生过。如果产生且成功递达,将执行默认操作。
    • SIGINT 信号会阻塞。由其 pending 状态为 1 可知已经产生该信号,并处于阻塞状态。虽然其处理动作是忽略,但在阻塞期间,进程也可能改变处理方式。
    • SIGQUIT 信号会阻塞。由其 pending 状态未 0 可知暂未产生该信号。如果产生,并且解除阻塞、成功递达,将由用户自定义的sig_handler处理。

信号集合类型 sigset_t

  • 基本操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    #include <signal.h>
    int sigemptyset(sigset_t *set); // 将信号集 set 清空
    int sigfillset(sigset_t *set); // 将信号集 set 填满
    int sigaddset(sigset_t *set, int signo); // 添加信号
    int sigdelset(sigset_t *set, int signo); // 删除信号
    /* 成功返回 0,出错返回 -1 */

    int sigismember(const sigset_t *set, int signo); // 判断某个新城是否在信号集中
    /* 存在返回 1,不存在返回 0 */
  • 对进程中信号表的修改与读取

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #include <signal.h>
    int sigprocmask(int how, const sig_set *set, sigset_t *oset);
    /*
    返回值:成功返回 0,出错返回 -1
    功能:读取或修改进程的 block 表

    参数
    - set 输入型参数,若非空,将更改进程的信号屏蔽字(mask),由参数 how 指定更改方式
    - oset 是输出型参数
    若 oset 非空而 set 为空,读取进程当前的信号屏蔽字由 oset 传出
    若 oset 和 set 均非空,则将原来的信号屏蔽字备份到 oset 中,然后修改
    - how
    SIG_BLOCK 表示添加 set 中的信号屏蔽字,相当于 mask |= set
    SIG_UNBLOCK 表示删除 set 中的信号,相当于 mask |= ~set
    SIG_SETBLOCK 表示将信号屏蔽字设置为 set,相当于 mask = set

    【注意】
    信号集 sigset_t 本质上就是长整型变量。其二进制的 0/1 串对应进程的信号表
    */

    int sigpending(sigset_t *sig) // 获取进程的 pending 表
    /* 成功返回 0,出错返回 -1 */

    示例:在短时间内将把 SIGINT 信号设置为阻塞方式,连续按下 control + C 观察进程 pending 表的变化。随后解除阻塞,观察先前发送的 SIGINT 是否能被进程接收。

    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
    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <signal.h>

    void out_sigset(sigset_t *set) // 打印 pending 表
    {
    for (int i = 1; i <= 31; i++) {
    if (sigismember(set, i)) putchar('1');
    // 通过提供的接口判断表中是否有对应信号
    else putchar('0');
    }
    putchar('\n');
    }

    int main()
    {
    sigset_t set1, set2;
    sigemptyset(&set1); // 信号集必须要初始化
    sigemptyset(&set2);

    sigaddset(&set1, SIGINT);
    sigprocmask(SIG_BLOCK, &set1, NULL); // 将 SIGINT 设置为阻塞方式

    int i = 1;
    while (i < 5) {
    sigpending(&set2); // 每次循环获取 pending 表
    out_sigset(&set2); // 打印 pending 表
    sleep(2);
    i++;
    }
    sigprocmask(SIG_UNBLOCK, &set1, NULL); // 解除对 SIGINT 的阻塞,观察
    return 0;
    }

利用信号集处理信号

  • 内核的信号捕捉机制

    截屏2023-01-02 22.51.58

  • 信号处理类型 struct sigaction

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    struct sigaction {
    void (*sa_handler)(int);
    // 信号处理函数指针 / SIG_IGN / SIG_DFL
    void (*sa_sigaction)(int, siginfo_t *, void *);
    // 另一种信号处理函数,一般不用
    sigset_t sa_mask;
    // 信号处理期间屏蔽的信号(不包括注册的信号本身,注册的信号本身默认屏蔽)
    int sa_flags;
    /*
    - SA_RESTART 使被信号打断的系统调用自动重新发起
    - SA_NOCLDSTOP 使父进程在其子进程暂停或继续运行时不会收到 SIGCHLD 信号
    - SA_NOCLDWAIT 使父进程在其子进程退出时不会收到 SIGCHLD 信号,
    保证不会产生僵尸进程
    - SA_NODEFER 使对注册的信号的屏蔽无效(默认情况下都是有效的)
    - SA_RESETHAND 信号处理之后重新设置为默认处理方式
    - SA_SIGINFO 使用 sa_sigaction 作为信号处理函数
    某些系统中 sa_handler 和 sa_sigaction 会被放进联合中,不应同时设置
    */
    void (*sa_restorer)(void);
    // 已弃用
    };
  • signal函数不属于 POSIX 标准,各平台上的实现不尽相同。标准的注册信号的函数为sigaction

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <signal.h>
    int sigaction(int signum,
    const struct sigaction *act,
    struct sigaction *oldact);
    /*
    返回值:成功注册返回 0,出错返回 -1

    参数
    - 要注册的信号由 signum 指定
    - 信号处理函数由 act->sa_handler 指定
    - 信号处理期间要屏蔽的信号由 act->sa_mask 指定
    - 是否在信号处理后重新发起系统调用 / 是否在一次信号处理后将处理方式改为默认
    等特殊设置由 act->flags 指定,一般默认为 0
    - 对该信号的原有的处理方式通过参数 oldact 传出
    */

    示例:使用read读取标准输入并输出,若输入一半受到 SIGINT 信号干扰,允许继续输入。

    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
    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <signal.h>

    void sig_handler(int sig)
    {
    printf("\nRead is interupted.\nContinue:\n"); // 输出受干扰信息
    }

    int main()
    {
    struct sigaction act;
    act.sa_handler = sig_handler; // 设置信号处理函数
    sigemptyset(&act.sa_mask); // 初始化屏蔽信号集
    act.sa_flags = SA_RESTART; // 设置重新发起系统调用的方式
    sigaction(SIGINT, &act, NULL); // 注册信号

    char buf[512];
    int n;

    n = read(STDIN_FILENO, buf, 512);
    if (n >= 0) {
    buf[n] = '\0';
    printf("input success. read %d bytes: %s\n", n, buf);
    }

    return 0;
    }

    测试:设置在处理 SIGTSTP(control + Z)信号期间屏蔽 SIGQUIT(contro + \)信号。当调用信号处理函数时分别发送 SIGQUIT 和 SIGINT 信号,观察现象。

    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
    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <signal.h>

    void sig_handler(int sig)
    {
    printf("interupted by sig %d\n", sig);
    int i = 0;
    while (i++ < 3) {
    printf("handling...\n");
    sleep(2);
    }
    printf("handle over\n");
    }

    int main()
    {
    struct sigaction act;
    act.sa_handler = sig_handler;
    act.sa_flags = 0; // 一般情况置零即可
    sigemptyset(&act.sa_mask); // 初始化信号集
    sigaddset(&act.sa_mask, SIGQUIT); // 加入信号

    sigaction(SIGTSTP, &act, NULL); // 注册信号

    while (1) sleep(1);

    return 0;
    }

    /*
    在信号处理函数调用期间发送信号 SIGQUIT,发现进程未退出;处理结束后,进程退出。
    在信号处理函数调用期间发送信号 SIGINT,进程直接退出。
    【信号屏蔽字 sa_mask 仅在信号处理期间暂时阻塞信号,处理结束后仍将递达该信号】
    */

竞态条件与sigsuspend

  • 考察通过alarm实现的mysleep函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    void sig_alrm_handler(int sig)
    {
    // do nothing
    }
    void mysleep(unsigned int seconds)
    {
    struct sigaction act, oact;
    act.sa_handler = sig_alrm_handler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    sigaction(SIGALRM, &act, &oact); // 注册信号,并保存原有的操作

    alarm(seconds); // 设置定时器
    pause(); // 等待

    sigaction(SIGALRM, &oact, NULL); // 恢复原有的操作
    return;
    }
    • 默认情况下,SIGALRM 信号会终止进程,所以如欲响应该信号,必须注册,哪怕信号处理函数什么也不做。
    • 对于pause系统调用,如果信号处理动作是终止进程,则进程终止,pause也没有机会返回;如果信号处理动作是忽略,则pause继续使进程挂起;如果信号处理动作是捕获,则在信号处理函数调用完毕后,pause返回 -1,且errno被设置为EINTR
    • 这个函数的问题在于,假如在alarm设置定时器之后,系统转而运行优先级更高的进程,在此期间 SIGALRM 发送,则将处于未决状态。当优先级更高的进程运行完毕,SIGLARM 信号递达,直接调用信号处理函数后经内核返回用户态,则此时才执行pause,将产生错误。
  • 异步事件任何时候都有可能发生,这类由时序问题导致的错误称为竞态条件(race condition)。

    针对上述问题,必须在设置定时器之前屏蔽 SIGALRM 信号,再挂起等待之前解除屏蔽。但是,解除屏蔽和pause之间,仍存在竞态条件。因此,必须将解除信号屏蔽和挂起等待合并为一个原子操作(通过一条语句实现)

  • sigsuspend系统调用

    1
    2
    3
    4
    5
    6
    #include <signal.h>
    int sigsuspend(const sigset_t *sigmask);
    /*
    返回情况同 pause
    参数 sigmask 指定了等待期间阻塞的信号
    */

    针对上例正确的做法

    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
    void sig_alrm_handler(int sig) { }
    void mysleep(unsigned int seconds)
    {
    struct sigaction act, oact;
    sigset_t newmask, oldmask, susmask;

    act.sa_handler = sig_alrm_handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGALRM, &act, &oact); // 注册信号同上

    sigemptyset(&newmask);
    sigaddset(&newmask, SIGALRM); // 设置仅含 SIGALRM 的信号集
    sigprocmask(SIG_BLOCK, &newmask, &oldmask); // 使进程屏蔽 SIGLARM

    alarm(seconds);

    susmask = oldmask;
    sigdelset(&susmask, SIGALRM); // 从系统原有的屏蔽信号集中删去 SIGALRM
    sigsuspend(&susmask);
    // 保证在屏蔽原有信号的屏蔽、同时能够响应 SIGALRM 的情况下等待

    sigprocmask(SIG_SETMASK, &oldmask, NULL); // 恢复进程原来的信号屏蔽字
    sigaction(SIGALRM, &oact, NULL);
    // 恢复进程原来对 SIGALRM 的处理方式
    return;
    }