1 UNIX 基础知识
1.1 UNIX 体系结构
在严格意义上,可将操作系统定义为一种软件,它控制计算机硬件资源,提供程序运行环境。 一般而言,我们称此种软件为内核(kernel),它相对较小,位于环境的中心。 内核的接口被称为系统调用(systemcall)。公用函数库构建在系统调用接口上, 应用软件既可使用公用函数库,也可使用系统调用。shell 是一种特殊的应用程序, 它为运行其他应用程序提供了一个接口。 在广义上,操作系统包括了内核和一些其他软件,这些软件使得计算机能够发挥作用,并给予 计算机以独有的特性。 这些软件包括系统实用程序(system utilities)、应用软件、shell 以及公用函数库等。 例如,Linux 是 GNU 操作系统使用的内核。某些人将此操作系统称为 GNU/Linux,但是,更通常 的是将其简称为 Linux。虽然在严格意义上,这种表达方法并不正确,但是因为"操作系统"本身 具有双重含义,这还是可以理解的。
1.2 文件和目录
UNIX 系统由文件组成。UNIX 系统的成功与它的方便易用,关键就在文件系统。它是 "保持简单"这种哲学思想的一个最好的示例,同时向我们展示了这些精心选择的思想 被认真实现以后所取得的巨大成就。
1.2.1 文件系统
UNIX 文件系统是目录和文件组成的一种层次结构,目录的起点称为根(root), 其名字是一个字符/。目录(directory)是一个包含许多目录项的文件,在逻辑上, 可以认为每个目录项都包含一个文件名,同时还包含说明该文件属性的信息。文件属性是指 文件类型(是普通文件还是目录)、文件大小、文件所有者、文件权限(其他用户能否访问该文件) 以及文件最后的修改时间等。 这里有必要对文件类型和文件权限讲述更多的内容。 在 UNIX 系统把所有设备(包括磁盘、打印机、SOCKET,甚至目录等等)都看作文件, 这些种类各异的设备,就构成了 UNIX 系统中多种多样的文件类型。常见的有 普通文件、目录文件、字符设备文件、块设备文件等。稍后将介绍如何查看文件的类型。 UNIX 中的每个文件都有一组权限与其相连,它决定谁可以对这个文件进行什么样的操作。 但必须说明的是,这些权限设置对于 root 用户来说,是没有效果的。 因此,即使你已把情书文件的权限改为只有你是可读的,但是知道 root 口令的任何人 仍可以读到,所以,把敏感的材料放入文件系统之前,你要仔细考量。
1.2.2 路径名和文件名
目录中各个名字称为文件名(filename)。不能出现在文件名中的字符只有斜线(/) 和空操作符(null)两个。斜线用来分隔构成路径名的各文件名,空操作符则用来终止一个 路径名。 创建新目录时会自动创建两个文件名:.(称为点)和..(称为点-点)。 点指当前目录,点-点则指父目录。在最高层次的根目录中,点-点与点相同。 一个或多个以斜线分隔的文件名序列(也可以斜线开头)构成路径名(pathname), 以斜线开头的路径名称为绝对路径名(absolute pathname),否则称为相对路径名 (relative pathname)。相对路径名引用相对于当前目录的文件。文件系统根的名字(/)是一个 特殊的绝对路径名,它不含文件名。
1.2.3 常用命令
有大量的程序可以对文件进行操作,但是在这里,只介绍最频繁使用的那此命令。 对其它命令的任何问题,可随时通过 man 或 info 进行查找。
ls 不带参数时,列出当前目录的文件名;可以使用 -a 参数让其列出所有文件(包括 .,..,以及 . 开头的文件); 使用 -l 参数时,则以长列表方式显示,包括较多的信息,前面提到的文件类型、文件大小、文件所有者、文件权限 信息,都可通过这种方式查看到;如果后者带有文件,则只显示该文件的信息。 使用长列表显示时的内容格式大致如下所示:
-rwx-rw-rw- 1 you 2 Sep 27 12:37 junk
第一个 - 表示它是一个普通文件,如果是 d 则表示是一个目录,为 c 或 b 则分别表示是一个字符设备文件或一个 块设备文件;后面的 rwx 表示文件的拥有者(即 you)对该文件有可读(r)可写(w)可执行(x), 而 you 所在组的其他成员有可读可写权限,除此之外的其他成员也有可读可写权限。
ll 功能类似于 ls -la。
cd 改变当前工作目录。
cpcp file1 file2 把 file1 拷贝到 file2,若 file2 存在,则覆盖之。
mvmv file1 file2 把 file1 换名为 file2,若 file2 存在,则覆盖之。
rm 删除指定的文件,不能恢复;若使用 rf 参数可以删除文件夹。
cat 把指定文件的内容显示到屏幕上。
grepgrep pattern file 显示 file 文件中所有与 pattern 模式(字符串)匹配的行; 若在模式前使用 -v 参数,则显示不匹配的行;另外可以把其它程序的输出通过管道(|) 输入到 grep,让其作过滤并输出,即command | grep pattern。
tail 默认是显示文件最后十行的内容,加正整数(设为 N)参数时,显示最后 N 行,负整数(设为 -N)时, 则显示前 N 行;可以使用 -f 参数让其动态显示指定文件的每次更新情况。
diff 比较指定两个文件的差异。
chmod 修改文件属性中的文件权限信息,比如 chmod +x junk,可使用所有用户对 junk 文件 有可执行权限,chmod -x junk 则去掉所有用户的可执行权限;还可以在 +x 前加 u、 g 或 o 分别为文件拥有者、同组用户或其他用户增加对文件的可执行权限。
chown 修改文件拥有者,完整的命令格式为 chown user:group file 即把文件 file 的拥用者 改为 group 组的 user 用户。
> 使用文件名作其参数,则构成一个创建空文件的最快捷方式,如果已存在同名文件,则被覆盖。
1.3 输入和输出
1.3.1 文件描述符
文件描述符(file descriptor)通常是一个小的非负整数,内核用它标识一个特定进程 正在访问的文件。当内核打开一个已有文件或创建一个新文件时,它返回一个文件描述符。在读 写文件时,就可以使用它。
1.3.2 标准输入、标准输出和标准出错
按惯例,每当运行一个新程序时,所有的 shell 都为其打开三个文件描述符:标准输入(standard input)、 标准输出(standard output)以及标准出错(standard error)。 在 shell 编程中,常常需要把其中某两个文件进行合并,比较在编译的时候,会希望把编译过程信息以及 编译出错信息(即合并 1 和 2 文件)合并到一个日志文件当中,以便对编译过程进行分析。 这时可以使用大致这样的命令:
make >mk.log 2>&1
符号 2 > &1 让 shell 将标准错误输出合并到标准输出流中。这里的 & 仅是一个记号。 还可以通过 1 > &2 把标准输出加入到标准错误输出中。
2 UNIX 标准化及实现
2.1 UNIX 系统实现
UNIX 的各种版本和变体都起源于在 PDP-11 系统上运行的 UNIX 分时系统第 6 版(1976 年)和第 7 版(1979 年) (通常称为 V6 和 V7)。这两个版本是在贝尔实验室以外首先得到广泛应用的 UNIX 系统。从它们开始演变出三个分支:
AT&T 分支,从此导出了系统 III 和系统 V(被称为 UNIX 的商用版本)。
加州大学伯克利分校分支,从此导出 4.xBSD 实现。
由 AT&T 贝尔实验室的计算科学研究中心开发的 UNIX 研究版本,从此导出 UNIX 分时系统 第 8、第 9 版以及于 1990 年发布的最后一版第 10 版。
目前流行的 UNIX 体系的操作系统,大概有如下一些: 4.4BSD(Berkeley Software Distribution)、FreeBSD、Linux、Mac OS X 以及 Solaris(SUN 公司开发的 UNIX 系统版本)。
2.2 UNIX 标准化
随着 UNIX 实现的版本越来越多样化,人们开始关注如何实现各系统间代码的可移植性。 而早期开发的程序,通常都是基于 C 库和系统接口调用的。因此这两者的标准化工作很快就被提上了议程。
2.2.1 ISO C
1989 年下半年,C 程序设计语言的 ANSI 标准 X3.159-1989 得到批准。此标准已被采纳为国际标准 ISO/IEC 9899:1990(通常被称为 C89)。ISO 标准的意图是提供 C 程序的可移植性,使其能适合于大量不同的操作系统,而不只是 UNIX 系统。 因为所有现今的 UNIX 系统都提供 C 标准中定义的库例程,所以该标准库是很重要的,遵循标准的代码将有更好的移植性。 在 1999 年,ISO C 标准被更新为 ISO/IEC9899:1999(通常被称为 C99)。新标准显著改善了对进行数值处理的应用程序的支持。
表 1 ISO C 标准定义的头文件
头文件 说明 头文件 说明
< assert.h > 验证程序断言 < complex.h > 支持复数算术运算
< ctype.h > 字符类型 < errno.h > 出错
< fenv.h > 浮点环境 < float.h > 浮点常量
... ... ... ...
这里只列举了其中一部分 ISO C 标准定义的头文件,详细内容,可以查看 ISO C99 标准。 下面的几个表格,列出了部分经常会用的 C 标准库中的宏和函数。
表 2 < ctype.h > 字符测试宏
名称 含义
isalpha(c) 字母:a-z A-Z
issupper(c) 大写:A-Z
islower(c) 小写:a-z
isdigit(c) 数字:0-9
isxdigit(c) 十六进制数字:0-9 a-f A-F
isalnum(c) 字母或数字
isspace(c) 空格、Tab、换行、垂直Tab、换页、回车
ispunct(c) 非字母或控制字符或空白
isprint(c) 可打印字符:任何图形符
iscntrl(c) 控制字符:0 < =c < 040 || c==0177
isasscii(c) ASCII字符:0 < =c < =0177
表 3 列出了对字符串进行处理的函数。一般最好使用这些已有的函数,因为 它们是正确的并且针对特定机器作了优化,所以运行速度一般比自己编的函数要快。
表 3 标准字符串函数
名称 含义
strcat(s,t) 把字符串t连接到字符串s的后面,返回s
strncat(s,t,n) 把字符串t的前n个字符连接到字符串s的后面,返回s
strcpy(s,t) 将字符串t拷贝到s中,返回s
strncpy(s,t,n) 将字符串t的前n个字符拷贝到s中,返回s
strcmp(s,t) 比较s和t,根据是小于、等于或大于返回 < 0、0、 > 0
strncmp(s,t,n) 至多比较n个字符
strlen(s) 返回s的长度
strchr(s,c) 返回字符串中第一个指向字符c的指针,否则返回NULL
strrchr(s,c) 返回字符串中最后一个指向字符c的指针,否则返回NULL
atoi(s) 返回s的整数值
atof(s) 返回s的浮点值;需声明double atof()
malloc(n) 返回一个指向n字节内存的指针,如分配失败,则返回NULL
calloc(n,m) 返回一个指向n*m字节的指针,并设置为0,如果分配失败,则返回NULL
free(p) 释放由malloc或calloc分配的内存
表 4 一些 < stdio.h > 中的定义
名称 含义
stdin 标准输入
stdout 标准输出
stderr 标准错误输出
EOF 文件结束,一般是-1
NULL 无效指针,一般是0
FILE 用于定义文件指针
BUFSIZ 正常的I/O缓冲区大小(一般是512或1024)
getc(fp) 从流fp中返回一个字符
getchar() getc(stdin)
putc(c,fp) 向流fp输出一个字符c
putchar(c) putc(c,stdout)
feof(fp) 当文件结束时得到一个不为0的数
ferror(fp) 当文件出错时得到一个不为0的数
fileno(fp) 流fp的文件描述符
表 5 常用的标准 I/O 函数
名称 含义
fp=fopen(s,mode) 打开文件,mode为r、w、a时分别表示读、写、添加
c=getc(fp) 得到一个字符。即getc(stdin)
putc(c,fp) 放入一个字符。即putc(c,stdout)
ungetc(c,fp) 将字符放回输入文件fp,每次至多放回一个字符
scanf(format,a1,...) 把字符从stdin读入到a1,...。ai必须是指针
fscanf(fp,...) 从文件fp中读入
sscanf(s,...) 从字符串s中读入
printf(format,a1,...) 对a1,...进行格式化,输出到stdout
fprintf(fp,...) 输出到文件fp
sprintf(s,...) 输出到字符串s
fgets(s,n,fp) 从文件fp中至多读n个字符到s中
fputs(s,fp) 在文件fp中输出字符串s
fflush(fp) 将缓冲区中的数据写到fp中
fclose(fp) 关闭文件fp
fp=popen(s,mode) 为命令s打开一个管道
pclose(fp) 关闭管道fp
system(s) 运行命令s,等待其结束
2.2.2 IEEE POSIX
POSIX 是一系列由 IEEE(Institute of Electrical and Electronics Engineers,电气与电子一程师协会) 制定的标准。POSIX 指的是可移植的操作系统接口(Portable Operating System Interface)。它原来指的 只是 IEEE 标准 1003.1-1988(操作系统接口),后来则扩展成包括很多标记为 1003.1 的标准及标准草案。 虽然 1003.1 标准是以 UNIX 操作系统为基础的,但是它并不限于 UNIX 和类似于 UNIX 的系统。 IEEE 1003.1 工作组继续对标准做出修改,在后续的版本中逐渐加入多线程编程接口(称为 pthreads,指的就是 POSIX 线程)、 实时接口以及事件跟踪方面的扩展。
表6 POSIX 标准定义的必需头文件
头文件 说明 头文件 说明
< dirent.h > 目录项 < fcntl.h > 文件控制
< fnamtch.h > 文件名匹配类型 < glob.h > 路径名模式匹配类型
< grp.h > 组文件 < netdb.h > 网络数据库操作
... ... ... ...
这些库中包含了系统编程用于的各种功能,诸如文件控制、套接字(socket)、消息队列、信号量、共享存储、线程控制等等。
3 文件 I/O
3.1 文件描述符
对于内核而言,所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数。当打开一个 现有文件或创建一个新文件时,内核向进程返回一个文件描述符。当读或写一个文件时,使用 open 或 creat 返回的文件描述符标识该文件,将其作为参数传送给 read 或 write。 依照惯例,UNIX 系统 shell 使用文件描述符 0 与进程标准输入相关联,文件描述符 1 与标准输出相关联, 文件描述符 2 与标准出错输出相关联。 在依从 POSIX 的应用程序中,幻数 0、1、2 应当替换成符号常量
STDIN_FILENO、 STDOUT_FILENO、 STDERR_FILENO。这些常量都定义在头文件 < unistd.h > 中。 对于每个文件描述符,通常都定义有 open、creat、close、read 这样的一些系统调用。 对于普通文件的描述符而言,这些接口的功能与 C 库的 fxxxx 系列接口功能相当。 差别在于这里使用的是文件描述符标识每个文件,而不是使用 FILE 指针。 当然,像 open 并不仅仅是针对普通文件的,对于其它的设备文件,在应用层也是可以使用相同的接口进行操作的, 只是在 linux 内核当中,会根据文件的不同类型,选择不同的驱动程序实现用户所需的功能。
4 进程环境
4.1 main 函数
C 程序总是从 main 函数开始执行。main 函数的原型是
int main(int argc, char *argv[]);其中,argc 是命令行参数的数目,argv 是指向参数的各个指针所构成的数组。 当内核执行 C 程序时(使用一个 exec 函数),在调用 main 前先调用一个特殊的启动例程。 可执行程序文件将此启动例程指定为程序的起始地址-这是由连接编辑器设置的,而连接的编辑器则由 C 编译器(通常是 gcc)调用。启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用 main 函数做好安排。
4.2 进程终止
有 8 种方式使用进程终止(termination),其中 5 种为正常终止,它们是
从 main 返回。
调用 exit。
调用 _exit 或 _Exit。
最后一个线程从其启动例程返回。
最后一个线程调用 pthread_exit。
异常终止有 3 种方式,它们是
调用 abort。
接到一个信号并终止。
最后一个线程对取消请求做出响应。
有三个函数用于正常终止一个程序:_exit 和 _Exit 立即进入内核,exit 则先执行一些 清理处理(包括调用执行各终止处理程序,关闭所有标准 I/O 流等),然后进入内核。
4.3 命令行参数
当执行一个程序时,调用 exec 的进程可将命令行参数传递给该新程序。这是 UNIX shell 的一部分常规操作。 这也可以看作是进程间通信的一种方式。
4.4 环境表
每个进程都会接收到一张环境表。与参数表一样,环境表也是一个字符指针数组, 其中每个指针包含一个以 null 结束的 C 字符串的地址。全局变量 environ 则包含了该指针数组的地址:
extern char **environ;在历史上,大多数 UNIX 系统支持 main 函数带有三个参数,其中第三个参数就是环境表的地址:
int main(int argc, char *argv[], char *envp[]);因为 ISO C 规定 main 函数只有两个参数,而且第三个参数与全局变量 environ 相比也没有 带来更多益处,所以 POSIX.1 也规定应使用 environ 而不使用第三个参数。
4.5 C 程序的存储空间布局
从历史上讲,C 程序一直由下面几部分组成:
正文段。这是由 CPU 执行机器指令部分。
初始化数据段。
非初始化数据段。通常将此段称为 BBS (block started by symbol,由符号开始的块)段,在程序开 始执行之前,内核将此段中的数据初始化为 0 为空指针。出现在任何函数外的 C 声明
long sum[1000];
使此变量存放在非初始化数据段中。
栈。
堆。通常在堆中进行动态存储分配。
5 进程控制
5.1 进程标识符
每个进程都有一个非负整数型表示的唯一进程 ID。因为进程 ID 标识是唯一的,常将其用作 其他标识符的一部分以保证其唯一性。例如,作为名字的一部分来创建一个唯一的文件名。 虽然是唯一的,但是进程 ID 可以重用。当一个进程终止后,其进程 ID 就可以再次使用了。
5.2 fork
一个现有进程可以调用 fork 函数创建一个新进程。
#include pid_t fork(void);由 fork 创建的新进程被称为子进程(child process)。fork 函数被调用一次,但返回 两次。两次返回的唯一区别是子进程中返回值是 0,而父进程的返回值则是新子进程 ID,出错的时候将会返回 -1。 将子进程 ID 返回给父进程的理由是:因为一个进程的子进程可以有多个,除了在每次创建子进程时记录 其进程 ID,没有别的办法能获取一个进程的所有子进程 ID。fork 使子进程得到返回值 0 的理由是:一个进程 只会有一个父进程,所以子进程总是可以调用 getppid 以获得其父进程的进程 ID。 子进程和父进程继续执行 fork 调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、 堆和栈的副本。注意,这是子进程所拥有的副本。父、子进程并不共享这些存储空间部分,即完成子进程 派生后,父、子进程存储空间不再同步更新。但是父、子进程共享正文段。
5.3 vfork
vfork 与 fork 一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即 调用 exec(或 exit),于是也就不会存访该地址空间。 vfork 与 fork 之间的另一个区别是:vfork 保证子进程先运行,在它调用 exec 或 exit 之后父进程才可能被 调度运行。
5.4 wait 和 waitpid 函数
当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHLD 信号。因为子进程终止是个异步事件(这可以在 父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步通知。父进程可以选择忽略该信号,或者提供一个 该信号发生时即被调用执行的函数(信号处理程序)。对于这种信号的系统默认动作是忽略它。现在 需要知道的是调用 wait 或 waitpid 的进程可能会发生什么情况:
如果其所有子进程都还在运行,则阻塞。
如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
如果它没有任何子进程,则立即出错返回。
如果进程由于接收到 SIGCHLD 信号而调用 wait ,则可期望 wait 会立即返回。但是如果在任意 时刻调用 wait,则进程可能会阻塞。 这两个函数的区别如下:
在一个子进程终止前,wait 使其调用者阻塞,而 waitpid 有一个选项,可使调用者不阻塞。
waitpid 并不等待在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。
6 信号
6.1 信号概念
首先,每个信号都有一个名字。这些名字都以三个字符 SIG 开头。常见的信号有:SIGABRT 是夭折信号,当进程 调用 abort 函数时产生这种信号;SIGALRM 是闹钟信号,当由 alarm 函数设置的计时器超时后产生此信号。 在头文件 < signal.h > 中,可以看到这些信号的完整列表,它们都被定义为正整数(信号编号)。 很多条件可以产生信号:
当用户按某些终端键时,引发终端产生信号。
硬件异常产生信号:比如除数为 0,无效的内存引用等等。
进程调用 kill(2) 函数可将信号发送给另一个进程或进程组。
用户可用 kill(1) 命令将信号发送给其他进程。
可以要求内核在某个信号出现时按下列三种方式一进行处理,我们称之为信号的处理 或者与信号相关的协作。
忽略此信号。注意 SIGKILL 和 SIGSTOP 这两种信号决不能被忽略,因为它们向超级 用户提供了使用进程终止或停止的可靠方法。
捕捉信号。在用户函数中,可执行用户希望对这种事件进行的处理。注意,不能捕捉 SIGKILL 和 SIGSTOP 信号。
执行系统默认动作。注意,针对大多数信号的系统默认动作是终止进程。
很多信号,比如 SIGABRT 的"默认动作"为"终止+core"表示除了会终止该进程外,还会在进程当前工作目录的 core 文件中复制该进程的存储映像(该文件名为 core)。 大多数 UNIX 调度程序都使用 core 文件以检查进程终止时的状态。
6.2 signal 函数
UNIX 系统的信号机制最简单的接口是 signal2 函数。
#include void (*signal(int signo, void (*func)(int)))(int);signo 参数是信号名,func 的值是常量 SIG_IGN、常量 SIG_DFL 或当接到此信号 后要调用的函数的地址。如果指定 SIG_IGN,则向内核忽略此信号。如果指定 SIG_DFL,则表示接到 此信号后的动作是默认动作。当指定函数地址时,则在信号发生时,调用该函数,我们称这种处理 为"捕捉"该信号。称此函数为信号处理程序档(signal handler)或信号捕捉函数 (signal-catching function)。
6.3 不可靠的信号
在早期的 UNIX 版本中,信号是不可靠的。不可靠在这里指的是,信号可能会丢失:一个信号发生了, 但进程却可能一直不知道这一点。 早期版本中的一个问题是在进程每次接到信号对其进行处理时,随即将该信号动作复位为 默认值。因为要连续捕获信号时,通常要使用与下面代码相似的实现:
fun() { int sig_int(); /* my signal handling function */ ... signal(SIGINT, sig_int); /* establish handler */ ... } sig_int() { signal(SIGINT, sig_int); /* reestablish handler for next time */ /* process the signal ... */ }这段代码的一个问题是:从信号发生之后到在信号处理程序中调用 signal 函数之前这段时间 中有一个时间窗口。在此段时间中,可能发生另一次中断信号。第二个信号会导致执行默认动作, 而针对中断信号的默认动作是终止该进程。
6.4 sigaction 函数
sigaction 函数的功能是检查或修改与指定信号相关联的处理动作(或同时执行这两种操作)。 此函数取代了 UNIX 早期版本使用的 signal 函数。
#include int sigaction(int signo, const struct sigaction *restrict act, struct sigaction *restrict oact);其中,参数signo是要检测或修改其动作的信号编号。若act指针非空,则要修改其动作。 如果oact指针非空,则系统经由oact指针返回该信号的上一个动作。此函数使用 下列结构:
struct sigaction { void (*sa_handler)(int); /* addr of signal handler, */ /* or SIG_IGN, or SIG_DFL */ sigset_t sa_mask; /* additional signals to block */ int sa_flags; /* signal options */ /* alternate handler */ void (*sa_sigaction)(int, siginfo_t *, void *); };7 线程
7.1 线程概念
典型的 UNIX 进程可以看成只有一个控制线程:一个进程在同一时刻只做一件事情。 有了多个控制线程以后,在程序设计时可以把进程设计成在同一时刻能够做不止一件事, 每个线程处理各自独立的任务。这种方法有很多好处。
通过为每种事件类型的处理分配单独的线程,能够简化处理异步事件的代码。
多个进程必须使用操作系统提供的复杂机制才能实现内存和文件描述符的共享, 而多个线程自动地可以访问相同的存储地址空间和文件描述符。
有些问题可以通过将其分解从而改善整个程序的吞吐量。
交互的程序同样可以通过使用多线程实现响应时间的改善,多线程可以把程序 中处理用户输入输出的部分与其他部分分开。
进程的所有信息对该进程的所有线程都是共享的,包括可执行的程序文本、程序的全局内存和 堆内存、栈以及文件描述符。
7.2 线程标识
就像每个进程都有一个进程 ID 一样,每个线程也有一个线程 ID 。进程 ID 在整个系统中是 唯一的,但线程 ID 不同,它只在它所属的进程环境中有效。 线程 ID 用 pthread_t 数据类型来表示,必须使用函数来对两个线程 ID 进行比较。
#include int pthread_equal(pthread_t tid1, pthread_t tid2);线程可以通过调用 pthread_self 函数获得自身的线程 ID。
#include pthread_t pthread_self(void);当线程需要识别以线程 ID 作为标识的数据结构时,pthread_self 函数可以与 pthread_equal 函数一起使用。 例如,主线程可能把工作任务放在一个队列中,用线程 ID 来控制每个工作线程处理哪些作业。主线程把 新的作业放到一个工作队列中,由三个工作线程组成的线程池从队列中移出作业,每个线程并不是任意地 处理从队列顶端取出的作业,而是由主线程控制作业的分配,主线程在每个待处理作业的结构中放置处理该作业 的线程 ID,每个工作线程只能移出标有自己线程 ID 的作业。
7.3 线程的创建
新增的线程可以通过调用 pthread_create 函数创建。
#include int pthread_create(pthread_t *restrict attr, const pthread_attr_t *restrict attr, void *(*start_rtn)(void), void *restrict arg);当 pthread_create 成功返回时,由 tidp 指向的内存单元被设置为新创建 线程的线程 ID。attr 参数用于定制各种不同的线程属性。新创建的线程从 start_rtn 函数的地址开始运行。 线程创建时并不能保证哪个线程会先运行:是新创建的线程还是调用线程。
7.4 线程终止
如果进程中的任一线程调用了 exit,_Exit 或者 _exit,那么整个进程就会停止。 与此类似,如果信号的默认动作是终止进程,那么,把该信号发送到线程就会终止 整个进程。 单个线程可以通过下列三种方式退出,在不终止整个进程的情况下停止它的控制流。
线程只是从启动例程中返回,返回值是线程的退出码。
线程可以被同一进程中的其他线程取消。
线程调用 pthread_exit。
7.5 线程同步
当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。如果每个线程 使用的变量都是其他线程不会读取或修改的,那么不存在一致性问题。同样地,如果变量 是只读的,多个线程同时读取该变量也不会有一致性问题。但是,当某个线程可以修改变量, 而其他线程也可以读取或者修改这个变量的时候,就需要对这些线程进行同步,以确保它们在 访问变量的存储内容时不会访问到无效的数值。要实现这样的同步,线程不得不使用锁,在同 一时间只允许一个线程访问该变量。
7.5.1 互斥量
可以通过使用 pthread 的互斥接口保护数据,确保同一时间只有一个线程访问数据。互斥量 (mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。 在设计时需要规定所有的线程必须遵守相同的数据访问规则,只有这样,互斥机制才能正常工作。 如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在 使用共享资源前都获取了锁,也还是会出现数据不一致的问题。 互斥变量用 pthread_mutex_t 数据类型来表示,在使用互斥变量以前,必须首先对它进行初始化。 通常可以通过调用 pthread_mutex_init 函数进行初始化。如果动态地分配互斥量,那么 在释放内存前需要调用 pthread_mutex_destroy。 对互斥量进行加锁,需要调用 pthread_mutex_lock,如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁。对 互斥量解锁,需要调用 pthread_mutex_unlock。
7.5.2 避免死锁
如果线程试图对同一个互斥量加锁再次,那么它自身就会陷入死锁状态,使用互斥量时, 还有其他更不明显的方式也能产生死锁。例如,程序中使用多个互斥量时,如果允许一个线程一直 占有第一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程 也在试图锁住第一个互斥量,这时就会发生死锁。因为两个线程都在相互请求另一个线程拥有的资源, 所以这两个线程都无法向前运行,于是就产生死锁。 可以通过小心地控制互斥量加锁的顺序来避免死锁的发生。
7.5.3 读写锁
读写锁与互斥量类似,不过读写锁允许更高的并行性。读写锁可以有三种状态:读模式下加锁状态, 写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程 可以同时占有读模式的读写锁。读写锁非常适合于对数据结构读的次数远大于写的情况。
8 进程间通信
要在进程之间交换信息,除了经由 fork 或 exec 传送打开文件,或者通过文件系统进行数据传递外, 还存在诸多的进程通信技术,通常这被统称为-IPC(InterProcess Communication)。
8.1 管道
管道是 UNIX 系统 IPC 的最古老形式,并且所有 UNIX 系统都提供此种通信机制,但通常它存在如下 两种局限性:
历史上,它们是半双工。
它们只能在具有公共祖先的进程之间使用。通常,一个管道由一个进程创建,然后该进程调用 fork,此后父、子进程之间就可以应用该管道。
虽然新式的系统可能已经突破这些限制,但出于可移植性的考虑,一般不仍遵守原来的编程方法。
8.2 消息队列
消息队列是消息的链接表,存放在内核中并由消息队列标识符标识。通常把消息队列简称为队列 (queue),其标识符为队列 ID(queue ID)。 msgget 用于创建一个新队列或打开一个现有的队列。msgsnd 将新消息添加到队列尾端。每个 消息包含一个正长整型类型字段,一个非负长度以及实际数据字节(对应于长度), 所有这些都在将消息添加到队列时,传送给 msgsnd。msgrcv 用于从队列中取消息。
8.3 信号量
信号量(semaphore)与已经介绍过的 IPC 机构(管道、消息队列)不同。它是一个计数器, 用于多进程对共享数据对象的访问。 为了获得共享资源,进程需要执行下列操作:
测试控制该资源的信号量。
若此信号量的值为正,则进程可以使用该资源。进程将信号量值减 1,表示它使用了一个资源单位。
若此信号量的值为 0,则进程进入休眠状态,直至信号量大于 0。进程被唤醒后,它返回至第 1 步。
当进程不再使用由一个信号量控制的共享资源时,该信号量值增 1。如果有进程正在休眠等待此信号量,则唤醒它们。 要获得一个信号量 ID,要调用的第一个函数是 semget。
#include sem.h> int semget(key_t key, int nsems, int flag);semctl 函数包含了多种信号量操作。
#include sem.h> int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);8.4 共享存储
共享存储允许两个或更多进程共享一给定的存储区。因为数据不需要在客户进程和服务器进程之间复制,所以这 是最快的一种 IPC。使用共享存储时要掌握的唯一窍门是多个进程之间对一给定存储区的同步访问。若服务器 进程正在将数据放入共享存储区,则在它做完这一操作之前,客户进程不应当去取这些数据。通常,信号量 被用来实现对共享存储访问的同步,当然,记录锁也可用于这种场合。 为获得一个共享存储标识,调用的第一个函数通常是 shmget。
#include shm.h> int shmget(key_t key, size_t size, int flag);一旦创建了一个共享存储段,进程就可调用 shmat 将其连接到它的地址空间中。 当对共享存储段的操作已经结束时,则调用 shmdt 脱接该段。但这并不意味着 从系统中删除其标识符以及其数据结构。该标识符仍然存在,直至某个进程调用 shmctl (带 IPC_RMID 命令)特地删除它。
参考文献
[1] Brian W.Kernighan Rob Pike: UNIX 编程环境 机械工业出版社 (1999) [2] W.Richard Stevens Stephen A. Rago: UNIX 环境高级编程(第2版) 人民邮电出版社 (2006)