【语言学习】C高级使用 - hippowc/hippowc.github.io GitHub Wiki
一个程序的一份运行中的实例叫做一个进程。如果你屏幕上显示了两个终端窗口,你很 可能同时将一个终端程序运行了两次——你有两个终端窗口进程。每个窗口可能都运行着一 个 shell;每个运行中的 shell 都是一个单独的进程。当你从一个 shell 里面调用一个程序的时 候,对应的程序在一个新进程中运行;运行结束后 shell 继续工作。
Linux 系统中的每个进程都由一个独一无二的进程 ID(通常也被称为 pid)标识。进程 ID 是一个 16 位的数字,由 Linux 在创建新进程的时候自动依次分配。
每个进程都有一个父进程。 因此,你可以把 Linux 中的进程结构想象成一个树状结构,其中 init 进程就是树的“根”。 父进程 ID(ppid)就是当前进程的父进程的 ID。当需要从 C 或 C++程序中使用进程 ID 的时候,应该始终使用<sys/types.h>中定义的 pid_t 类型。程序可以通过 getpid()系统调用获取自身所运行的进程的 ID,也可以通过 getppid()系统调用获取父进程 ID。
#include <stdio.h>
#include <unistd.h>
int main () {
printf ("The proces ID is %d\n", (int) getpid ());
printf ("The parent process ID is %d\n", (int) getppid ()); return 0;
}
把这个程序运行几次并观察每次的结果,会发现每次都会输出一个不同的进程 ID,因 为每次运行这个程序都建立了一个新进程。但是,如果你每次都从同一个 shell 里面调用, 父进程 ID(也就是 shell 进程的 ID)并不会改变。
第一种方法相对简单,但是在使用之前应慎重考虑,因 为它效率低下,而且具有不容忽视的安全风险。第二种方法相对复杂了很多,但是提供了更 好的弹性、效率和安全性。
C 标准库中的 system 函数提供了一种调用其它程序的简单方法。利用 system 函数调用 程序结果与从 shell 中执行这个程序基本相似。事实上,system 建立了一个运行着标准 Bourne shell(/bin/sh)的子进程,然后将命令交由它执行。
#include <stdlib.h>
int main () {
int return_value;
return_value = system ("ls -l /");
return return_value;
}
因为 system 函数使用 shell 调用命令,它受到系统 shell 自身的功能特性和安全缺陷的 限制。你不应该试图依赖于任何特定版本的 Bourne shell。因此,fork 和 exec 才是推荐用于创建进程的方法。
使用 fork 和 exec;DOS 和 Windows API 都包含了 spawn 系列函数。这些函数接收一个要运行的程序名作 为参数,启动一个新进程中运行它。Linux 没有这样一个系统调用可以在一个步骤中完成这 些。
Linux 提供了一个 fork 函数,创建一个调用进程的精确拷贝。exec 族函数,使一个进程由运行一个程序的实例转换到运行另 外一个程序的实例。要产生一个新进程,应首先用 fork 创建一个当前进程的副本,然后使 用 exec 将其中一个进程转为运行新的程序。
一个进程通过调用 fork 会创建一个被称为子进程的副本进程。父进程从调用 fork 的地 方继续执行;子进程也一样。如何区分两个进程?首先,子进程是一个新建立的进程,因此有一个与父进程不 同的进程 ID。因此可以通过调用 getpid 检测自身运行在子进程还是父进程。不过,fork 函 数对父子进程提供了不同的返回值——一个进程“进入”fork 调用,而另外一个则从调用 中“出来”。父进程得到的 fork 调用返回值是子进程的 ID。子进程得到的返回值是 0。因为 任何进程的 ID 均不为 0,程序可以藉此很轻松的判断自身运行在哪个进程中。
一个使用 fork 复制进程的例子。需要注意的是,if 语句的第一段将仅在父进 程中运行,而 else 部分则在子进程中运行。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main () {
pid_t child_pid;
printf ("the main program process ID is %d\n", (int) getpid ());
child_pid = fork ();
if (child_pid != 0) {
printf ("this is the parent process, with id %d\n", (int) getpid ());
printf ("the child's process ID is %d\n", (int) child_pid); }
else
printf ("this is the child process, with id %d\n", (int)getpid ());
return 0;
}
Exec 族函数用一个程序替换当前进程中正在运行的程序。当某个 exec 族的函数被调用时,如果没有出现错误的话,调用程序会被立刻中止,而新的程序则从头开始运行。Exec 族函数在名字和作用方面有细微的差别。
- 名称包含p字母的函数(execvp和execlp)接受一个程序名作为参数,然 后在当前的执行路径(译者注:环境变量 PATH 指明的路径)中搜索并执行这个 程序;名字不包含 p 字母的函数在调用时必须指定程序的完整路径。
- 名称包含l字母的函数(execl、execlp和execle)接收一个字符串数组作 为调用程序的参数;这个数组必须以一个 NULL 指针作为结束的标志。名字包含 v 字母的函数(execv,execvp和execve)以C语言中的varg(s译者注:原文为varargs, 疑为笔误)形式接受参数列表。
- 名称包含e字母的函数(execve和execle)比其它版本多接收一个指明了 环境变量列表的参数。这个参数的格式应为一个以 NULL 指针作为结束标记的字 符串数组。每个字符串应该表示为“变量=值”的形式。
传递给程序的参数列表和当你从 shell 运行时传递给程序的命令行参数相似。新程序可 以从 main 函数的 argc 和 argv 参数中获取它们。请记住,当一个程序是从 shell 中被调用的 时候,shell 程序会将第一个参数(argv[0])设为程序的名称,第二个参数(argv[1])为第 一个命令行参数,依此类推。当你在自己的程序中使用 exec 函数的时候,也应该将程序名 称作为第一个参数传递进去。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
/* 产生一个新进程运行新的程序。PAORGAM 是要运行的程序的名字;系统会在 执行路径中搜索这个程序运行。ARG_LIST 是一个以 NULL 指针结束的字符串列表, 用作程序的参数列表。返回新进程的 ID。 */
int spawn (char* program, char** arg_list)
{
pid_t child_pid; /* 复制当前进程。*/
child_pid = fork ();
if (child_pid != 0)
/* 这里是父进程。*/
return child_pid;
else {
/* 现在从执行路径中查找并执行 PROGRAM。*/ execvp (program, arg_list);
/* execvp 函数仅当出现错误的时候才返回。*/
fprintf (stderr, "an error occurred in execvp\n"); abort ();
}
}
int main () {
/* 准备传递给 ls 命令的参数列表 */ char* arg_list[] = {
"ls", /* argv[0], 程序的名称 */
"-l",
"/",
NULL /* 参数列表必须以 NULL 指针结束 */
};
/* 建立一个新进程运行 ls 命令。忽略返回的进程 ID */ spawn ("ls", arg_list);
printf ("done with main program\n");
return 0; }
Linux 会分别独立地调度父子进程;不保证进程被调度的先后顺序,也不保证被调度的 进程在被另外一个(或系统中其它进程)打断之前会运行多久。具体来说,ls命令也许在父 进程结束之前根本没有被调度运行,也可能是ls命令运行了一部分或者全部完成之后主进程 才结束执行。
你可以将一个进程标记为次要的;给进程指定一个较高的niceness值会给这个进程分配 较低的优先级。默认情况下,每个进程的 niceness 均为 0。较高的 niceness 值代表了较低的 进程优先级;相应的,较低的 niceness 值(负值)表示较高的进程优先级。
使用 nice 命令的-n 参数允许用户以一个指定的 niceness 值运行特定程序。例如,要执 行“sort input.txt > output.txt”这个会执行较长时间的排序命令,可以通过下面 的命令降低它的优先级,使它不会过度地影响系统的运行:
nice -n 10 sort input.txt > output.txt
你可以使用 renice 命令从命令行调整一个正在运行的程序的 niceness 值。
信号(Signal)是 Linux 系统中用于进程之间相互通信或操作的一种机制。信号是一个 相当广泛的课题;在这里,我们仅仅探讨几种最重要的信号以及利用信号控制进程的技术。
信号是一个发送到进程的特殊信息。信号机制是异步的;当一个进程接收到一个信号时, 它会立刻处理这个信号,而不会等待当前函数甚至当前一行代码结束运行。信号有几十种,分别代表着不同的意义。信号之间依靠它们的值来区分,但是通常在程序中使用信号的名字 来表示一个信号。在 Linux 系统中,这些信号和以它们的名称命名的常量均定义在 /usr/include/bits/signum.h 文件中。(通常程序中不需要直接包含这个头文件,而应 该包含<signal.h>。)
当一个进程接收到信号,基于不同的处理方式(disposition),该进程可能执行几种不同 操作中的一种。每个信号都有一个默认处理方式(default disposition),当进程没有指定自己 对于某个信号的处理方式的时候,默认处理方式将被用于对对应信号作出响应。对于多数种 类的信号,程序都可以自由指定一个处理方式——程序可以选择忽略这个信号,或者调用一 个特定的信号处理函数。如果指定了一个信号处理函数,当前程序会暂停当前的执行过程, 同时开始执行信号处理函数,并且当信号处理函数返回之后再从被暂停处继续执行。
Linux 系统在运行中出现特殊状况的时候也会向进程发送信号通知。例如,当一个进程 执行非法操作的时候可能会收到 SIGBUS(主线错误),SIGSEGV(段溢出错误)及 SIGFPE (浮点异常)这些信号。这些信号的默认处理方式都是终止程序并且产生一个核心转储文件 (core file)。
个进程除了响应系统发来的信号,还可以向其它进程发送信号。对于这种机制的一个 最常见的应用就是通过发送SIGTERM或SIGKILL信号来结束其它进程。除此之外,它 还常见于向运行中的进程发送命令。两个“用户自定义”的信号SIGUSR1 和SIGUSR2 就是 专门作此用途的。SIGHUP信号有时也用于这个目的——通常用于唤醒一个处于等待状态的 进程或者使进程重新读取配置文件。
系统调用 sigaction 用于指定信号的处理方式。函数的第一个参数是信号的值。之后两 个参数是两个指向 sigaction 结构的指针;第一个指向了将被设置的处理方式,第二个用于保存先前的处理方式。这两个 sigaction 结构中最重要的都是 sa_handler 域。它可以是下面 三个值
- SIG_DFL,指定默认的信号处理方式
- SIG_IGN,指定该信号将被忽略
- 一个指向信号处理函数的指针。这个函数应该接受信号值作为唯一参数,且没有返回值。
为信号处理是异步进行的,当信号处理函数被调用的时候,主程序可能处在非常脆弱的状态,并且这个状态会一直保持到信号处理函数结束。因此,应该尽量避免在信号处理函 数中使用输入输出功能、绝大多数库函数和系统调用。
信号处理函数应该做尽可能少的工作以响应信号的到达,然后返回到主程序中继续运行,多数情况下,所进行的工作只是记录信号的到达。而主程序则定期检查 是否有信号到达,并且针对当时情况作出相应的处理。信号处理函数也可能被其它信号的到达所打断。虽然这种情况听起来非常罕见,一旦出 现,程序将非常难以确定问题并进行调试。
甚至于对全局变量赋值可能也是不安全的,因为一个赋值操作可能由两个或更多机器指 令完成,而在这些指令执行期间可能会有第二个信号到达,致使被修改的全局变量处于不完 整的状态。
如果你需要从信号处理函数中设置全局标志以记录信号的到达,这个标志必须是 特殊类型 sig_atomic_t 的实例。Linux 保证对于这个类型变量的赋值操作只需要一条机器指 令,因此不用担心可能在中途被打断。在 Linux 系统中,sig_atomic_t 就是基本的 int 类型; 事实上,对 int 或者更小的整型变量以及指针赋值的操作都是原子操作。不过,如果你希望 所写的程序可以向任何标准 UNIX 系统移植,则应将所有全局变量设为 sig_atomic_t 类型
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
sig_atomic_t sigusr1_count = 0;
void handle (int signal_number)
{
++sigusr1_count;
}
int main () {
struct sigaction sa;
memset (&sa, 0, sizeof (sa));
sa.sa_handler = &handler;
sigaction (SIGUSR1, &sa, NULL);
/* 这里可以执行一些长时间的工作。*/ /* ... */
printf ("SIGUSR1 was raised %d times\n", sigusr1_count);
return 0; }
进程会以两种情况的之一结束:调用 exit 函数退出或从 main 函数返回。每个 进程都有退出值(exit code):一个返回给父进程的数字。一个进程退出值就是程序调用exit 函数的参数,或者 main 函数的返回值。
进程也可能由于信号的出现而异常结束。例如,之前提到的 SIGBUS,SIGSEGV 和 SIGFPE 信号的出现会导致进程结束。其它信号也可能显式结束进程。当用户在终端按下 Ctrl+C 时会发送一个 SIGINT 信号给进程。SIGTERM 信号由 kill 命令发送。这两个信号 的默认处理方式都是结束进程。进程通过调用 abort 函数给自己发送一个 SIGABRT 信号, 导致自身中止运行并且产生一个 core file。最强有力的终止信号是 SIGKILL,它会导致进程 立刻终止,而且这个信号无法被阻止或被程序自主处理。
这里任何一个信号都可以通过指定一个特殊选项,由 kill 命令发送
kill -KILL pid
要从程序中发送信号,使用 kill 函数。第一个参数是目标进程号。第二个参数是要发 送的信号;传递 SIGTERM 可以模拟 kill 命令的默认行为。需要包含<sys/types.h>和<signal.h>头文件才能在程序中调用 kill 函数。
kill (child_pid, SIGTERM);
根据习惯,程序的退出代码可用来确认程序是否正常运行。返回值为 0 表示程序正确运 行,而非零的返回值表示运行过程出现错误。在后一种情况下,返回值可能表示了特定的错 误含义。通常应该遵守这个约定,因为 GNU/Linux 系统的其它组件会假设程序遵循这个行 为模式。例如,当使用 &&(逻辑与)或 ||(逻辑或)连接多个程序的时候,shell 根据这 个假定判断逻辑运算的结果。因此,除非有错误发生,你都应该在 main 结束的时候明确地 返回 0。
如果你输入并且运行了代码列表 3.4 的 fork 和 exec 示例程序,你可能已经发现,ls 程 序的输入很多时候出现在“主程序”结束之后。这是因为子进程,也就是运行 ls 命令的进 程,是相独立于主进程被调度的。因为 Linux 是一个多任务操作系统,两个进程看起来是并 行执行的,而且你无法猜测 ls 程序会在主程序运行之前还是之后获取运行的机会。
在某些情况下,主程序可能希望暂停运行以等待子进程完成任务。可以通过 wait 族系统调用实现这一功能。这些函数允许你等待一个进程结束运行,并且允许父进程得到子 进程结束的信息。Wait 族系统调用一共有四个函数
这一族函数中,最简单的是 wait。它会阻塞调用进程,直到某一个子进程退出(或者 出现一个错误)。
/* 产生一个子进程运行 ls 命令。忽略返回的子进程 ID。*/
spawn ("ls", arg_list);
/* 等待子进程结束。*/
wait (&child_status);
如果一个子进程结束的时候,它的父进程正在调用 wait 函数,子进程会直接消失,而 退出代码则通过 wait 函数传递给父进程。但是,如果子进程结束的时候,父进程并没有调 用 wait,则又会发生什么?它是不是简单地就消失了呢?不,因为如果这样,它退出时返 回的相关信息——譬如它是否正常结束,以及它的退出值——会直接丢失掉。在这种情况下, 子进程死亡的时候会转化为一个僵尸进程。
一个僵尸进程是一个已经中止而没有被清理的进程。清理僵尸子进程是父进程的责任。 Wait 函数会负责这个清理过程,所以你不必在等待一个子进程之前检测它是否正在运行。 假设,一个进程创建了一个子进程,进行了另外一些计算,然后调用了 wait。如果子进程 还没有结束,这个进程会在 wait 调用中阻塞,直到子进程结束。如果子进程在父进程调用 wait 之前结束,子进程会变成一个僵尸进程。当父进程调用 wait,僵尸子进程的结束状态 被提取出来,子进程被删除,并且 wait 函数立刻返回。
制作一个僵尸进程
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main () {
pid_t child_pid;
/* 创建一个子进程 */
child_pid = fork ();
if (child_pid > 0) {
/*这是父进程。休眠一分钟。 */
sleep (60); }
else {
/*这是子进程。立刻退出。 */
exit (0); }
return 0;
}
如果 make-zombie 进程退出而没有调用 wait 会出现什么情况?僵尸进程会停留在系统 中吗?不——试着再次运行 ps,你会发现两个 make-zombie 进程都消失了。当一个程序退 出,它的子进程被一个特殊进程继承,这就是 init 进程。Init 进程总以进程 ID 1 运行(它 是 Linux 启动后运行的第一个进程)。Init 进程会自动清理所有它继承的僵尸进程。
如果你创建一个子进程只是简单的调用 exec 运行其它程序,在父进程中立刻调用 wait 进行等待并没有什么问题,只是会导致父进程阻塞等待子进程结束。但是,很多时候你希望 在子进程运行的同时,父进程继续并行运行。怎么才能保证能清理已经结束运行的子进程而 不留下任何僵尸进程在系统中浪费资源呢?
一种解决方法是让父进程定期调用 wait3 或 wait4 以清理僵尸子进程。在这种情况调 用 wait 并不合适,因为如果没有子进程结束,这个调用会阻塞直到子进程结束为止。然而, 你可以传递 WNOHANG 标志给 wait3 或 wait4 函数作为一个额外的参数。如果设定了这 个标志,这两个函数将会以非阻塞模式运行——如果有结束的子进程,它们会进行清理;否 则会立刻返回。第一种情况下返回值是结束的子进程 ID,否则返回 0。
另外一种更漂亮的解决方法是当一个子进程结束的时候通知父进程。有很多途径可以做 到这一点;在第五章“进程间通信”介绍了这些方法,不过幸运的是 Linux 利用信号机制替 你完成了这些。当一个子进程结束的时候,Linux 给父进程发送 SIGCHLD 信号。这个信号 的默认处理方式是什么都不做;因此,一个简单的清理结束运行的子进程的方法是响应 SIGCHLD 信号。当然,当清 理子进程的时候,如果需要相关信息,一个很重要的工作就是保存进程退出状态,因为一旦 用 wait 清理了进程,就再也无法得到这些信息了。
#include <signal.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
sig_atomic_t child_exit_status;
void clean_up_child_process (int signal_number)
{
/* 清理子进程。*/
int status;
wait (&status);
/* 在全局变量中存储子进程的退出代码。*/ child_exit_status = status;
}
int main () {
/* 用 clean_up_child_process 函数处理 SIGCHLD。*/ struct sigaction sigcihld_action;
memset (&sigchld_action, 0, sizeof (sigchld_action)); sigcihld_action.sa_handler = &clean_up_child_process; sigaction (SIGCHLD, &sigchld_action, NULL);
/* 现在进行其它工作,包括创建一个子进程。*/ /* ... */
return 0; }
线程,不同于进程,是一种允许一个程序同时执行不止一个任务的机制。于进程相似, 不同线程看起来是并行运行的;Linux 核心对它们进行异步调度,不断中断它们的执行以给 其它线程执行的机会。
概念上,线程出现在进程中。相比进程,线程是一种更细粒度的执行单元。当你调用一 个程序,Linux 创建一个新进程,并且在那个新进程中创建一个线程;这个线程依序执行程 序。这个线程可以创建更多的线程;所有这些线程在同一个进程中执行同一个程序,但是每 个线程在特定时间点上可能分别执行这个程序的不同部分。
我们已经看到一个进程如何创建新进程。子进程开始时候运行父进程的程序,并且从父 进程处复制了虚拟内存、文件描述符和其它信息。子进程可以修改自己的内存、关闭文件描 述符、执行其它各种操作,但是这些操作不会影响父进程;反之亦然
不过,当一个程序创 建了一个线程时并不会复制任何东西。创建和被创建的线程同先前一样共享内存空间、文件 描述符和其它各种系统资源。例如,当一个线程修改了一个变量的值,随后其它线程就会看 到这个修改过的值。相似的,如果一个线程关闭了一个文件描述符,其它线程也无法从这个 文件描述符进行读或写操作。因为一个进程中所有线程只能执行同一个程序,如果任何一个 线程调用了一个 exec 函数,所有其它线程就此终止
GNU/Linux 实现了 POSIX 标准线程 API(所谓 pthreads)。所有线程函数和数据类型 都在 <pthread.h> 头文件中声明。这些线程相关的函数没有被包含在 C 标准库中,而是在 libpthread 中,所以当链接程序的时候需在命令行中加入 -lpthread 以确保能正确链接。
进程中的每个线程都以线程 ID 标识。在 C 或 C++ 程序中,线程 ID 被表示为 pthread_t 类型的值。
创建线程时,每个线程都开始执行一个线程函数。这只是一个普通的函数,包含了线程 应执行的代码;当函数返回的时候,线程也随之结束。在 GNU/Linux 系统中,线程函数接 受一个 void* 类型的参数,并且返回 void* 类型。这个参数(parameter)被称为线程参数 (thread argument):GNU/Linux 系统不经查看直接将它传递给线程。你的程序可以利用这 个参数给新线程传递数据。相似的,你的线程可以利用返回值给它的创建者线程返回数据。
函数 pthread_create 负责创建新线程。你需要给它提供如下信息:
- 一个指向 pthread_t 类型变量的指针;新线程的线程 ID 将存储在这里。
- 一个指向线程属性(thread attribute)对象的指针。这个对象控制着新线程与程序其它部分交互的具体细节。如果传递 NULL 作为线程属性,新线程将被赋予一组默认线程属 性。
- 一个指向线程函数的指针。类型如下:void* () (void)
- 一个线程参数,类型 void*。不论你传递什么值作为这个参数,当线程开始执行的 时候,它都会被直接传递给新的线程。
函数 pthread_create 会在调用后立刻返回,原线程会继续执行之后的指令。同时,新 线程开始执行线程函数。Linux 异步调度这两个线程,因此你的程序不能依赖两个线程得到 执行的特定先后顺序。
创建线程
#include <pthread.h>
#include <stdio.h>
/* 打印 x 到错误输出。没有使用参数。不返回数据。*/
void* print_xs (void* unused)
{
while (1)
fputc ('x', stderr);
return NULL;
}
/* 主程序 */
int main () {
pthread_t thread_id;
/* 传教新线程。新线程将执行 print_xs 函数。*/ pthread_create (&thread_id, NULL, *print_xs, NULL); /* 不断输出 o 到标准错误输出。*/
while (1)
fputc ('o', stderr);
return 0;
}
使用以下命令编译链接这个程序: cc -o thread-create thread-create.c -lpthread
在一般状况下,一个线程有两种退出方式。一种方式,如先前所示,是从线程函数中返 回以退出线程。线程函数的返回值也被作为线程的返回值。另一种方式则是线程显式调用 pthread_exit。这个函数可以直接在线程函数中调用,也可以在其它直接、间接被线程函数 调用的函数中调用。调用 pthread_exit 的参数就是线程的返回值。
线程参数提供了一种为新创建的线程传递数据的简便方式。因为参数是 void*,你无法通过参数本身直接传递大量数据,而应使用线程参数传递一个指向某个数据结构或数组的指 针。一个常用的技巧是给线程函数定义一个结构以包含线程函数所期待的实际参数序列。
利用线程参数可以很轻易地重用一个线程函数创建许多线程。所有这些线程可以针对不 同的数据执行相同的操作。与前一个例子非常相似。这个程序会创建两个新线程,一个输出 x 而另一个输出 o。不同于之前的不停输出,每个线程输出固定的字符数之后就从线程函数中 返回以退出线程。同一个函数 char_print 在两个线程中均被执行,但是程序为每个线程指 定不同的 struct char_print-parms 实例作为参数。
#include <pthread.h>
#include <stdio.h>
/* print_function 的参数 */
struct char_print_parms
{
/* 用于输出的字符 */ char character; /* 输出的次数 */ int count;
};
/* 按照 PARAMETERS 提供的数据,输出一定数量的字符到 stderr。
PARAMETERS 是一个指向 struct char_print_parms 的指针 */
void* char_print (void* parameters)
{
/* 将参数指针转换为正确的类型 */
struct char_print_parms* p = (struct char_print_parms*) parameters; int i;
for (i = 0; i < p->count; ++i)
fputc (p->character, stderr);
return NULL;
}
/* 主程序 */
int main () {
pthread_t thread1_id;
pthread_t thread2_id;
struct char_print_parms thread1_args;
struct char_print_parms thread2_ars;
完美废人 译
/* 创建一个线程输出 30000 个 x */
thread1_args.character = 'x';
thread1_args.count = 30000;
pthread_create (&thread1_id, NULL, &char_print, &thread1_args);
/* 创建一个线程输出 20000 个 o */
thread2_args.character = 'o';
thread2_args.count = 20000;
pthread_create (&thread2_id, NULL, &char_print, &thread2_args);
return 0; }
程序有一个严重的错误。主线程(就是执行 main 函数的 线程)将线程参数结构(thread1_args 和 thread2_args)创建为局部变量,然后将指向它们 的指针传递给创建的线程。如何防止main 在另外两个线程结束 之前结束?没有办法!一旦这个情况发生,包含线程参数结构的内存将在被两个线程访问的 同时被释放。
一个解决办法是强迫 main 函数等待另外两个线程的结束。我们需要一个类似 wait 的函 数,但是等待的是线程而不是进程。这个函数是 pthread_join。它接受两个参数:线程 ID, 和一个指向 void*类型变量的指针,用于接收线程的返回值。如果你对线程的返回值不感兴 趣,则将 NULL 作为第二个参数。
在主程序返回之前增加这两行代码:
/* 确保第一个线程结束 */
pthread_join (thread1_id, NULL);
/* 确保第二个线程结束 */
pthread_join (thread2_id, NULL);
一旦你将对某个数据变量的引用传递给某个线程,务必确保这个变量 在不会被释放(甚至在其它线程中也不行!),直到你确定这个线程不会再使用它。这对于局 部变量(当生命期结束的时候自动释放)和堆上分配的对象(通过 free 或者 C++的 delete 手工释放)同样适用。
如果传递给pthread_join的第二个参数不是 NULL,则线程返回值会被存储在这个指针 指向的内存空间中。线程返回值,与线程变量一样,也是void类型。如果你想要返回一个 int或者其它小数字,你可以简单地把这个数值强制转换成void指针并返回,并且在调用 pthread_join之后把得到的结果转换回相应的类型1。
有时候,一段代码需要确定是哪个线程正在执行到这里。可以通过 pthread_self 函数获取调用线程 ID。所得到的线程 ID 可以用 pthread_equal 函数与其它线程 ID 进行比较。这些函数可以用于检测当前线程 ID 是否为一特定线程 ID。例如,一个线程利用pthread_join 等待自身是错误的。
if (!pthread_equal (pthread_self (), other_thread))
pthread_join (other_thread, NULL);
线程属性,线程取消,同步和异步线程,线程专有数据,同步和临界代码段。。。 这几个有些深入先略过
GNU/Linux 平台上的 POSIX 线程实现与其它许多类 UNIX 操作系统上的实现有所不同: 在 GNU/Linux 系统中,线程就是用进程实现的。每当你用 pthread_create 创建一个新线程 的时候,Linux 创建一个新进程运行这个线程的代码。不过,这个进程与一般由 fork 创建的 进程有所不同;具体来说,新进程与父进程共享地址空间和资源,而不是分别获得一份拷贝。
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
void* thread_function (void* arg)
{
fprintf (stderr, "child thread pid is %d\n", (int) getpid ()); /* 无限循环 */
while (1);
return NULL;
}
int main () {
pthread_t thread;
fprintf (stderr, "main thread pid is %d\n", (int) getpid ()); pthread_create (&thread, NULL, &thread_function, NULL);
/* 无限循环 */
while (1);
return 0;
}
然后调用 ps x 显示运行中的进程。注意这里共有三个进程运行着 thread-pid 程序。第一个,进程号是 14608 的,运行的是 程序的主函数;第三个,进程号是 14610 的,是我们创建来执行 thread_function 的线程。 那么第二个,进程号是 14609 的线程呢?它是“管理线程”,属于 GNU/Linux 线程内部 实现细节。管理线程会在一个程序第一次调用 pthread_create 的时候自动创建。
假设一个多线程程序收到了一个信号。究竟哪个线程的信号处理函数会作出响应?线程 和信号之间的互操作在各个 UNIX 变种系统都可能有所不同。在 GNU/Linux 系统中,这个 行为的决定因素在于:线程实际是由进程实现的。
因为每个线程都是一个单独的进程,又因为信号是发送到特定进程的,究竟由哪个线程 接受信号并不会成为一个问题。一般而言,从程序外发送的信号通常都是发送到程序的主线 程。
对于一些从并发处理中受益的程序而言,多进程还是多线程可能很难被抉择。这里有一 些基本方针可以帮助你判断哪种模型更适合你的程序:
- 一个程序的所有线程都必须运行同一个执行文件。而一个新进程则可以通过 exec 函数运行一个新的执行文件。
- 由于所有线程共享地址空间和资源,一个错误的线程可能影响所有其它线程。例如, 通过未经初始化的指针非法访问内存可能破坏其它线程所使用的内存。 而一个错误的进程则不会造成这样的破坏因为每个进程都有父进程的地址空间的 完整副本。
- 为新进程复制内存会比创建新线程存在性能方面的损失。不过,由于只有当对内存 进行写入操作的时候复制操作才会发生,如果新进程只对内存执行读取操作,性能 损失可能微乎其微。
- 对于需要精细并行控制的程序,线程是更好的选择。例如,如果一个问题可以被分 解为许多相对独立的子任务,用线程处理可能更好。进程适合只需要比较粗糙的并 行程序。
- 由于线程之间共享地址空间,共享数据是一件简单的任务。(不过如前所述,必须 倍加小心防范竞争状态的出现。)进程之间共享属于要求使用第五章中介绍的各种 IPC 机制。这虽然显得更麻烦而笨重,但同时避免了许多并行程序错误的出现。
我们讨论了进程的创建方法,也展示了一个进程如何获取子进程的退 出状态。这可以算是最简单的进程间通信方法,但毋庸置疑,它绝不是是最强大的一种。
进程间通信(Interprocss communication, IPC)是在不同进程之间传递数据的方法。例如, 互联网浏览器可以向服务器发送一个请求,随后服务器会传回 HTML 信息。这样的数据传 递通常是通过一种功能类似电话线路连接的套接字来完成的。
另外一个例子,你可以用 ls | lpr 这个命令将一个目录下的文件名打印出来。Shell 程序会创建一个 ls 进程和一个 lpr 进程,然后用一个“管道(用 | 符号表示)”将它们连接起来。管道为这两个进程提供了一 种单向通信的渠道。这个例子中,由 ls 进程向管道写入信息,而 lpr 进程则从管道读取。
五种不同的进程间通信机制:
- 共享内存允许两个进程通过对特定内存地址的简单读写来完成通信过程。
- 映射内存与共享内存的作用相同,不过它需要关联到文件系统中的一个文件上。
- 管道允许从一个进程到另一个关联进程之间的顺序数据传输。
- FIFO 与管道相似,但是因为 FIFO 对应于文件系统中的一个文件,无关的进程也可以完成通信
- 套接字允许无关的进程、甚至是运行在不同主机的进程之间相互通信。
共享内存是进程间通信中最简单的方式之一。共享内存允许两个或更多进程访问同一块 内存,就如同 malloc() 函数向不同进程返回了指向同一个物理内存区域的指针。当一个 进程改变了这块地址中的内容的时候,其它进程都会察觉到这个更改。
访问共享内存区域和访问进程独有的内存区域一样快,并不需要通过系统调用或者其它需要切入内核的过程来完成。同时它也避免了对数据的各种不必要的复制。因为系统内核没有对访问共享内存进行同步,你必须提供自己的同步措施。例如,在数据被写入之前不允许进程从共享内存中读取信息、不允许两个进程同时向同一个共享内存地 址写入数据等。解决这些问题的常用方法是通过使用信号量进行同步。
进程通过调用shmget(SHared Memory GET,获取共享内存)来分配一个共享内存块。 该函数的第一个参数是一个用来标识共享内存块的键值。彼此无关的进程可以通过指定同一 个键以获取对同一个共享内存块的访问。不幸的是,其它程序也可能挑选了同样的特定值作 为自己分配共享内存的键值,从而产生冲突。用特殊常量 IPC_PRIVATE 作为键值可以保证 系统建立一个全新的共享内存块。
要让一个进程获取对一块共享内存的访问,这个进程必须先调用 shmat(SHared Memory Attach,绑定到共享内存)。将 shmget 返回的共享内存标识符 SHMID 传递给这个 函数作为第一个参数。该函数的第二个参数是一个指针,指向你希望用于映射该共享内存块 的进程内存地址;如果你指定 NULL 则 Linux 会自动选择一个合适的地址用于映射。
调用shmctl("SHared Memory ConTroL",控制共享内存)函数会返回一个共享内存 块的相关信息。同时 shmctl 允许程序修改这些信息。该函数的第一个参数是一个共享内存 块标识。 要获取一个共享内存块的相关信息,则为该函数传递 IPC_STAT 作为第二个参数,同 时传递一个指向一个 struct shmid_ds 对象的指针作为第三个参数。 要删除一个共享内存块,则应将 IPC_RMID 作为第二个参数,而将 NULLL 作为第三 个参数。当最后一个绑定该共享内存块的进程与其脱离时,该共享内存块将被删除。
共享内存块提供了在任意数量的进程之间进行高效双向通信的机制。每个使用者都可以 读取写入数据,但是所有程序之间必须达成并遵守一定的协议,以防止诸如在读取信息之前 覆写内存空间等竞争状态的出现。不幸的是,Linux 无法严格保证提供对共享内存块的独占 访问,甚至是在你通过使用IPC_PRIVATE创建新的共享内存块的时候也不能保证访问的独 占性。
当访问共享内存的时候,进程之间必须相互协调以避免竞争状态 的出现。正如我们在第四章“线程”中 4.4.5 节“线程信号量”里说过的,信号量是一个可 用于同步多线程环境的计数器。Linux 还提供了一个另外一个用于进程间同步的信号量实现 (通常它被称为进程信号量,有时也被称为 System V 信号量)。进程信号量的分配、使用和 释放方法都与共享内存块相似。
与用于分配、释放共享内存的 shmget 和 shmctl 类似,系统调用 semget 和 semctl 负责分配、释放信号量。调用 semget 函数并传递如下参数:一个用于标识信号量组的键值, 该组中包含的信号量数量和与 shmget 所需的相同的权限位标识。该函数返回的是信号量组 的标识符。你可以通过指定正确的键值来获取一个已经存在的信号量的标识符;这种情况下, 传递的信号量组的容量可以为 0。
信号量会一直保存在系统中,甚至所有使用它们的进程都退出后也不会自动被销毁。最 后一个使用信号量的进程必须明确地删除所使用的信号量组,来确保系统中不会有太多闲置 的信号量组,从而导致无法创建新的信号量组。可以通过调用 semctl 来删除信号量组。
映射内存提供了一种使多个进程通过一个共享文件进行通信的机制。尽管可以将映射内 存想象为一个有名字的共享内存,你始终应当记住两者之间有技术层面的区别。映射内存既 可以用于进程间通信,也可以作为一种访问文件内容的简单方法。
映射内存在一个文件和一块进程地址空间之间建立了联系。Linux 将文件分割成内存分 页大小的块并复制到虚拟内存中,因此进程可以在自己的地址空间中直接访问文件内容。这 样,进程就可以以读取普通内存空间的方法来访问文件的内容,也可以通过写入内存地址来 修改文件的内容。这是一种方便的访问文件的方法。
你可以将映射内存想象成这样的操作:分配一个足够容纳整个文件内容的缓存,将全部 文件内容读入缓存,并且(当缓存内容被修改过后)最后将缓存写回文件。Linux 替你完成 文件读写的操作。
要将一个普通文件映射到内存空间,应使用 mmap(映射内存,“Memory MAPped”,读 作“em-map”)。函数 mmap 的第一个参数指明了你希望 Linux 将文件映射在进程地址空间中 的位置;传递 NULL 指针允许 Linux 系统自动选择起始地址。第二个参数是映射内存块的长 度,以字节为单位。第三个参数指定了对被映射内存区域的保护,由 PROT_READ、 PROT_WRITE 和 PROT_EXEC 三个标志位按位与操作得到。三个值分别标识读、写和执 行权限。第四个参数是一个用于指明额外选项的标志值。第五个参数应传递一个已经打开的、 指向被映射文件的句柄。最后一个参数指明了文件中被映射区域相对于文件开始位置的偏移 量。通过选择适当的开始位置和偏移量,你可以选择将文件的全部内容或某个特定部分映射 到内存中。
不同进程可以将同一个文件映射到内存中,并借此进行通信。通过指定 MAP_SHARED 标志,所有对映射内存的写操作都会直接作用于底层文件并且对其它进程可见。如果不指定 这个标志,Linux 可能在将修改写入文件之前进行缓存。
系统调用 mmap 还可以用于除进程间通信之外的其它用途。一个常见的用途就是取代 read 和 write。例如,要读取一个文件的内容,程序可以不再显式地读取文件并复制到内 存中,而是将文件映射到地址空间然后通过内存读写操作来操作文件内容。对于一些程序而 言这样更方便,也可能具有更高的效率。
许多程序都使用了这样一个非常强大的高级技巧:将某种数据结构(例如各种 struct 结构体的实例)直接建立在映射内存区域中。在下次调用过程中,程序将这个文件映射回内 存中,此时这些数据结构都会恢复到之前的状态。不过需要注意的是,这些数据结构中的指 针都会失效,除非这些指针都指向这个内存区域内部并且这个内存区域被特意映射到与之前 一次映射位置完全相同的地址。
管道是一个允许单向信息传递的通信设备。从管道“写入端”写入的数据可以从“读取 端”读回。管道是一个串行设备;从管道中读取的数据总保持它们被写入时的顺序。一般来 说,管道通常用于一个进程中两个线程之间的通信,或用于父子进程之间的通信。
要创建一个管道,请调用pipe命令。提供一个包含两个int值的数组作为参数。Pipe 命令会将读取端文件描述符保存在数组的第 0 个元素而将写入端文件描述符保存在第 1 个 元素中。
通过调用pipe得到的文件描述符只在调用进程及子进程中有效。一个进程中的文件描 述符不能传递给另一个无关进程;不过,当这个进程调用 fork 的时候,文件描述符将复制 给新创建的子进程。因此,管道只能用于连接相关的进程。
先入先出(first-in, first-out, FIFO)文件是一个在文件系统中有一个名字的管道。任何 进程均可以打开或关闭 FIFO;通过 FIFO 连接的进程不需要是彼此关联的。FIFO 也被称为 命名管道。
可以用 mkfifo 命令创建 FIFO;通过命令行参数指定 FIFO 的路径。例如,运行这个 命令将在/tmp/fifo 创建一个 FIFO
通过编程方法创建一个 FIFO 需要调用 mkfifo 函数。第一个参数是要创建 FIFO 的路 径,第二个参数是被创建的 FIFO 的属主、属组和其它用户权限。
套接字是一个双向通信设备,可用于同一台主机上不同进程之间的通信,也可用于沟通 位于不同主机的进程。套接字是本章中介绍的所有进程间通信方法中唯一允许跨主机通信的 方式。Internet 程序,如 Telnet、rlogin、FTP、talk 和万维网都是基于套接字的。
当你创建一个套接字的时候你需要指定三个参数:通信类型,命名空间和协议。
通信类型决定了套接字如何对待被传输的数据,同时指定了参与传输过程的进程数量。 当数据通过套接字发送的时候会被分割成段落,这些段落分别被称为一个包(packet)。通 信类型决定了处理这些包的方式,以及为这些包定位目标地址的方式。
套接字的命名空间指明了套接字地址的书写方式。套接字地址用于标识一个套接字连接 的一个端点。例如,在“本地命名空间”中的套接字地址是一个普通文件。而在“Internet 命名空间”中套接字地址由网络上的一台主机的 Internet 地址(也被称为 Internet 协议地址 或 IP 地址)和端口号组成。端口号用于区分同一台主机上的不同套接字。
协议指明了数据传输的方式。常见的协议有如下几种:TCP/IP,Internet 上使用的最主 要的通信协议;AppleTalk 网络协议;UNIX 本地通信协议等。通信类型、命名空间和协议 三者的各种组合中,只有部分是有效的。
Socket 和 close 函数分别用于创建和销毁套接字。当你创建一个套接字的时候,需指 明三种选项:命名空间,通信类型和协议。利用 PF_开头(标识协议族,protocol families) 的常量指明命名空间类型。例如,PF_LOCAL 或 PF_UNIX 用于标识本地命名空间,而 PF_INET 表示 Internet 命名空间。用以 SOCK_开头的常量指明通信类型。SOCK_STREAM 表示连接类型的套接字,而 SOCK_DGRAM 表示数据报类型的套接字。 第三个参数,协议,指明了发送和接收数据的底层机制。每个协议仅对一种命名空间和 通信类型的组合有效。因为通常来说,对于某种组合都有一个最合适的协议,为这个参数指 定 0 通常是最合适的选择。如果 socket 调用成功则会返回一个表示这个套接字的文件描述 符。与操作普通文件描述符一样,你可以通过 read 和 write 对这个套接字进行读写。当 你不再需要它的时候,应调用 close 删除这个套接字。
要在两个套接字之间建立一个连接,客户端需指定要连接到的服务器套接字地址,然后 调用 connect。客户端指的是初始化连接的进程,而服务端指的是等待连接的进程。客户 端调用 connect 以在本地套接字和第二个参数指明的服务端套接字之间初始化一个连接。 第三个参数是第二个参数中传递的标识地址的结构的长度,以字节计。套接字地址格式随套 接字命名空间的不同而不同。
专门用于操作套接字的 send 函数提供了 write 之外 的另一种选择,它提供了 write 所不具有的一些特殊选项
服务器的生命周期可以这样描述:创建一个连接类型的套接字,绑定一个地址,调用 listen 将套接字置为监听状态,调用 accept 接受连接,最后关闭套接字。数据不是直接 经由服务套接字被读写的;每次当程序接受一个连接的时候,Linux 会单独创建一个套接字 用于在这个连接中传输数据。
要通过套接字连接同一台主机上的进程,可以使用符号常量 PF_LOCAL 和 PF_UNIX 所代表的本地命名空间。它们被称为本地套接字(local sockets)或者 UNIX 域套接字 (UNIX-domain sockets)。它们的套接字地址用文件名表示,且只在建立连接的时候使用。
要使用一块共享内存,进程必须首先分配它。随后需要访问这个共享内存块的每一个进 程都必须将这个共享内存绑定到自己的地址空间中。当完成通信之后,所有进程都将脱离共 享内存,并且由一个进程释放该共享内存块。
理解 Linux 系统内存模型可以有助于解释这个绑定的过程。在 Linux 系统中,每个进程 的虚拟内存是被分为许多页面的。这些内存页面中包含了实际的数据。每个进程都会维护一 个从内存地址到虚拟内存页面之间的映射关系。尽管每个进程都有自己的内存地址,不同的 进程可以同时将同一个内存页面映射到自己的地址空间中,从而达到共享内存的目的。
分配一个新的共享内存块会创建新的内存页面。因为所有进程都希望共享对同一块内存 的访问,只应由一个进程创建一块新的共享内存。再次分配一块已经存在的内存块不会创建 新的页面,而只是会返回一个标识该内存块的标识符。一个进程如需使用这个共享内存块, 则首先需要将它绑定到自己的地址空间中。这样会创建一个从进程本身虚拟地址到共享页面 的映射关系。当对共享内存的使用结束之后,这个映射关系将被删除。当再也没有进程需要 使用这个共享内存块的时候,必须有一个(且只能是一个)进程负责释放这个被共享的内存 页面。
所有共享内存块的大小都必须是系统页面大小的整数倍。系统页面大小指的是系统中单 个内存页面包含的字节数。在 Linux 系统中,内存页面大小是 4KB,不过你仍然应该通过调 用 getpagesize 获取这个值。