Linux 多进程、多线程编程

Linux 是多任务操作系统,可以同时运行多个进程,来完成多项工作。

进程就是处于活动状态的程序,占用一定的内存空间。进程可以新建进程,新的进程称为 子进程,原来的进程称为 父进程

进程可以复制自己。这意味着启动一个程序,可能会产生多个进程。这样的程序能同时进行多项工作。多进程编程就是要设计一个这样的程序。

进程的状态

进程从创建到运行结束,经历的全部过程,称为进程的生命周期。在生命周期的不同阶段,进程会呈现不同的状态。下表列出了进程可能出现的所有状态。

状态 含义
创建状态 正在创建
就绪 刚刚创建好,还没运行过
内核状态 运行中
用户状态 暂停中
睡眠 运行中的进程因为某些需求得不到满足而进入等待状态
唤醒 睡眠中的进程,正在被唤醒
被抢占 运行期间,CPU 被另一个进程抢占
僵死状态 进程已经结束,但内存空间等占用的资源还未释放,被称为僵尸进程

进程的管理

每个进程都有一个唯一的编号,记为 PID (process id)。PID 应该用 pid_t 类型的变量来保存,pid_t 类型其实是 unsigned int 的别名。

下面两个函数一个返回当前进程的 PID,一个返回父进程的 PID

1
2
3
4
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);

在 Linux 系统中,除了 init 进程,其他进程都有一个父进程。对于在终端启动程序,它的父进程就是 Shell。下面程序在运行时输出自己的 PID 和父进程的 PID

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

int main()
{
pid_t pid;
pid_t ppid;

pid = getpid();
ppid = getppid();

printf("pid: %d\n", pid);
printf("ppid: %d\n", ppid);

return 0;
}

fork() 复制进程

fork() 函数以父进程为蓝本,产生一个子进程。

1
2
3
#include <unistd.h>

pid_t fork(void);

对于 fork() 函数的返回值,如果创建失败,将得到 -1;如果创建成功,在父进程中将得到子进程的 PID,在子进程中将得到 0

如果在 fork() 函数之后用一个 if 语句对 fork() 函数的返回值进行判断,子进程和父进程将进入不同的分支。

下面程序中的 if 语句让父进程输出 parent hello!,而子进程输出 child hello!

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

int main()
{
pid_t pid;

pid = fork();
if (pid == -1) {
perror("fork error");
return -1;
}

if (pid == 0) {
printf("child hello!\n");
} else {
printf("parent hello!\n");
}

return 0;
}

sleep() 进入睡眠

调用 sleep() 函数,进程将进入睡眠状态,传递给 sleep() 函数的参数就是睡眠的持续时间,单位秒。下面代码将使进程进入睡眠状态,持续 3 秒钟。

1
2
3
#include <unistd.h>

sleep(3);

wait() 等待进程结束、exit() 结束进程

父进程调用 wait(),将进入睡眠状态,直至子进程进入僵死状态,返回值是子进程的 PID。子进程调用 exit() 将使自己进入僵死状态。

1
2
3
4
5
#include <sys/wait.h> // wait()
#include <stdlib.h> // exit()

pid_t wait(int * status);
void exit(int status);

这两个函数都只有一个参数。exit() 函数的参数是一个整型变量,用来保存一个范围在 0-255 之间的整数,最终这个整数的地址会保存在 wait() 函数的参数中。

进程间通信

信号机制是进程间的一种通信机制。信号是一系列整数,用来代表不同的事情发生。信号可以被进程捕获,捕获时会调用提前指定的信号处理函数,信号会作为参数传递。信号可以是由另一进程产生,也可由内核发出。

实际上,子进程调用 exit() 时就会产生一个名为 SIGCHLD 的信号,父进程只要提前为这个信号指定处理函数就好,而不用专门调用 wait() 来等待。

signal() 绑定信号处理函数

调用 signal() 函数可以为某个信号指定处理函数。

1
2
3
4
#include <signal.h>

typedef void (* sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t sighandler);

signal() 函数只接受两个参数,一个用于指定信号,一个用于给出信号的处理函数。信号处理函数必须是无返回值、只接受一个整数作为参数的函数。

如果成功,signal() 函数的返回值是原本的信号处理函数,否则返回 SIG_ERR

信号 SIGINT 是一个在前台运行的进程在用户按下 Ctrl + C 时可以捕获到的信号。下面程序将信号 SIGINT 的处理函数设为 sig_handler(),这个函数会输出 interrupted!,故用户按下 Ctrl + C 时会提示 interrupted!

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

void sig_handler(int sig)
{
printf("interrupted!\n");
exit(0);
}

int main()
{
if (signal(SIGINT, sig_handler) == SIG_ERR) {
perror("signal error");
return -1;
}

while (1);
}

信号处理函数除了可以是自定义的函数,还可以使用 SIG_IGNSIG_DFL 这两个宏。

1
2
3
4
#include <bits/signum.h>

#define SIG_IGN ((sighandler_t) 0)
#define SIG_DFL ((sighandler_t) 1)

SIG_IGN 表示忽略目标信号;SIG_DFL 表示采用信号的默认处理方式。

处理僵尸进程

子进程终止时,父进程可以捕获 SIGCHLD 信号。如果父进程不对 SIGCHLD 信号进行处理,子进程会继续占用资源而成为僵尸进程。

最简单的解决办法是把 SIGCHLD 信号的处理函数指定为 SIG_IGN

1
signal(SIGCHLD, SIG_IGN);

kill()、raise() 发送信号

进程可以用 signal() 函数给信号绑定信号处理函数,一旦信号到来,信号处理函数就会被调用。进程还可以用 kill() 函数给另一进程发送信号,或者用 raise() 函数给自己发送信号。

1
2
3
4
#include <signal.h>

int kill(pid_t pid, int sig);
int raise(int sig);

kill() 函数在成功时返回 0,失败时返回 -1,errno 的可能取值如下:

  • EINVAL 无效的信号
  • EPERM 无权限发送信号
  • ESRCH 目标不存在

下面程序创建的子进程在给信号 SIGINT 绑定处理函数后进入死循环,父进程在睡眠 3 秒后向子进程发送信号 SIGINT,使得子进程中的信号处理函数被调用,最终输出 interrupted!

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

void sig_handler(int sig)
{
printf("interrupted!\n");
exit(0);
}

int main()
{
pid_t pid;

pid = fork();
if (pid == -1) {
perror("fork error");
return -1;
}

if (pid == 0) {
if (signal(SIGINT, sig_handler) == SIG_ERR) {
perror("signal error");
return -1;
}
while (1);
} else {
sleep(3);
kill(pid, SIGINT);
}

return 0;
}

多线程

进程进一步细分,就是线程。每一个进程都至少有一个线程,这个线程称为 主线程。主线程就是运行主函数 main() 的线程。创建线程相当于调用一个函数,只不过原来的线程会立即执行后续的代码而不等待这个函数返回。这使得被调函数中的代码和后续的代码是并行执行的。因此,可以简单地认为多线程就是同时运行多个函数。

历史上曾出现过多种线程标准。这些标准互不兼容,这使得程序员难以开发可移植的应用程序。为此,IEEE 制订了后来被广泛采用的线程标准 POSIX threads,简称 Pthreads。POSIX 线程库 实现了这个标准。POSIX 线程库也是最常用的线程库。使用 POSIX 线程库需要包含头文件 pthread.h

1
#include <pthread.h>

由于 POSIX 线程库并不属于默认库,因此在使用 gcc 命令进行编译时,要加上 -lpthread 选项。

pthread_create() 创建线程

线程通过调用 pthread_create() 函数创建。

1
2
3
4
5
6
int pthread_create(
pthread_t * id,
pthread_attr_t * attr,
void * (* start_routine)(void *),
void * arg
);
  • 第一个参数要求一个 pthread_t 变量的地址。这个变量用来保存线程的标识符
  • 第二个参数要求一个 pthread_attr_t 结构的地址。这个结构用于设定线程的一些属性,一般设为 0
  • 第三个参数要求一个函数。创建的线程将调用这个函数。这个函数称为 线程函数。线程函数必须以一个 void 指针为参数,返回值也必须是一个 void 指针。
  • 第四个参数是一个 void 指针,它将会作为线程函数的参数。如果不需要传参,设为 0

如果线程创建成功,pthread_create() 函数将返回 0,否则返回要给错误代码。这些错误代码是线程库定义的一些常量,但没有一个是 -1

pthread_exit() 结束线程

线程调用 pthread_exit() 函数可结束自己,这个函数相当于结束进程的 exit()

1
void pthread_exit(void * retval);

唯一的参数是一个 void 指针,用来指向返回值。

pthread_join() 等待线程结束

可以调用 pthread_join() 函数来等待另一个线程结束。

1
int pthread_join(pthread_t id, void ** retval);
  • 第一个参数要求一个线程的标识符
  • 第二个参数要求一个 void 指针的地址。这个指针将被指向线程的返回值。如果不需要得到线程的返回值,可设为 0

如果顺利,pthread_join() 函数将返回 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
#include <stdio.h>
#include <stdlib.h> // exit()
#include <unistd.h> // fork(), sleep(), _exit(), pid_t
#include <pthread.h>

void * hello(void * arg)
{
printf("Thread start running!\n");
printf("%s\n", (char *)arg);
sleep(3);
pthread_exit("Hello from thread!");
}

int main(void)
{
pthread_t id;
void * thread_retval;

if (pthread_create(&id, 0, hello, "Hello from main!") != 0) {
printf("Create thread failed!\n");
exit(1);
}

pthread_join(id, &thread_retval);
printf("%s\n", (char *)thread_retval);
return 0;
}

pthread_detach() 脱离同步

pthread_detach() 函数用来使一个线程与其他线程脱离同步。脱离同步是指其他线程不能用 pthread_join() 函数来等待这个线程结束。这个线程将在退出时自行释放所占的资源。

1
int pthread_detach(pthread_t id);

pthread_detach() 函数唯一的参数就是需要脱离同步的线程的标识符。如果顺利,将返回 0,否则返回一个错误代码。