Linux系统编程
SUSv3和POSIX.1-2001
始于1999年,出于修订并加强POSIX标准和SUS规范的目的,IEEE、Open集团以及ISO/ IEC联合技术委员会共同成立了奥斯丁公共标准修订工作组(CSRG,https://www.opengroup.org/austin/ )。(该工作组的首次会议于1998年9月在德州奥斯丁召开,这也是奥斯丁工作组名称的由来。)2001年12月,该工作组正式批准了POSIX 1003.1-2001,有时简称为POSIX.1-2001(随后,又获批为ISO标准:ISO/IEC 9945:2002)。
POSIX 1003.1-2001取代了SUSv2、POSIX.1、POSIX.2以及大批的早期POSIX标准。有时,人们也将该标准称为Single Unix Specification版本3,本书在后续内容中将称其为SUSv3。
SUSv3基本规范约有3700页,分为以下4部分。
- 基本定义(XBD),包含了定义、术语、概念以及对头文件内容的规范。总计提供了84个头文件的规范。
- 系统接口(XSH),首先介绍了各种有用的背景信息。主要内容包含对各种函数(在特定的UNIX实现中,这些函数要么是作为系统调用,要么是作为库函数来实现的)的定义。总计包括了1123个系统接口。
- Shell和实用工具(XCU),明确定义了shell和各种UNIX命令的行为。总共定义了160个实用工具的行为。
- 基本原理(XRAT),包括了与前三部分有关的描述性文字和原理说明。
系统调用
系统调用是受控的内核入口,借助于这一机制,进程可以请求内核以自己的名义去执行某些动作。以应用程序编程接口(API)的形式,内核提供有一系列服务供程序访问。这包括创建新进程、执行I/O,以及为进程间通信创建管道等。
在Linux上,系统调用服务例程遵循的惯例是调用成功则返回非负值。
可以以getppid()系统调用为例,研判一下发起系统调用的开销——该系统调用只是简单地返回调用进程的父进程ID。在作者的一台运行Linux 2.6.25的x86-32系统上,调用getppid()一千万次大约需要2.2秒钟,每次调用大致需要0.3微秒。相形之下,在同一系统上,调用某个只返回整数的C语言函数一千万次,仅需0.11秒,约为调用getppid()耗费时间的1/20。当然,大多数系统调用的开销都明显高于getppid()。
库函数
GNUC
一个库函数是构成标准C语言函数库的众多库函数之一。(出于简化,本书后文提到某具体函数时,通常将其称为“函数”而非“库函数”。)库函数的用途多种多样,可用来执行以下任务:打开文件、将时间转换为可读格式,以及进行字符串比较等。
许多库函数(比如,字符串操作函数)不会使用任何系统调用。另一方面,还有些库函数构建于系统调用层之上。例如,库函数fopen()就利用系统调用open()来执行打开文件的实际操作。往往,设计库函数是为了提供比底层系统调用更为方便的调用接口。例如,printf()函数可提供格式化输出和数据缓存功能,而write()系统调用只能输出字节块。同理,与底层的brk()系统调用相比,malloc()和free()函数还执行了各种登记管理工作,内存的释放和分配也因此而容易许多。
标准C语言函数库的实现随UNIX的实现而异。GNU C语言函数库(glibc, https://www.gnu.org /software/libc/ )是Linux上最常用的实现。
//查看linux上gnuc的版本
#include<gnu/libc-version.h>
const char *gnu_get_libc_version(void);
POSIX标准
术语“POSIX(可移植操作系统Portable Operating System Interface的缩写)”是指在IEEE(电器及电子工程师协会),确切地说是其下属的可移植应用标准委员会(PASC, https://www.pasc.org/ )赞助下所开发的一系列标准。PASC标准的目标是提升应用程序在源码级别的可移植性。
符合POSIX.1标准的操作系统应向程序提供调用各项服务的API,POSIX.1文档对此作了规范。凡是提供了上述API的操作系统都可被认定为符合POSIX.1标准。
POSIX.1基于UNIX系统调用和C语言库函数,但无需与任何特殊实现相关。这意味着任何操作系统都可以实现该接口,而不一定要是UNIX操作系统。实际上,在不对底层操作系统大加改动的同时,一些厂商通过添加 API 已经使自己的专有操作系统符合了 POSIX.1标准。
POSIX.2(1992,ISO/IEC 9945-2:1993)这一与POSIX.1相关的标准,对shell和包括C编译器命令行接口在内的各种UNIX工具进行了标准化。
内核的职责
进程调度:计算机内均配备有一个或多个CPU(中央处理单元),以执行程序指令。与其他UNIX系统一样,Linux属于抢占式多任务操作系统。“多任务”意指多个进程(即运行中的程序)可同时驻留于内存,且每个进程都能获得对CPU的使用权。“抢占”则是指一组规则。这组规则控制着哪些进程获得对CPU的使用,以及每个进程能使用多长时间,这两者都由内核进程调度程序(而非进程本身)决定。
内存管理:以一二十年前的标准来看,如今计算机的内存容量可谓相当可观,但软件的规模也保持了相应地增长,故而物理内存(RAM)仍然属于有限资源,内核必须以公平、高效地方式在进程间共享这一资源。与大多数现代操作系统一样,Linux也采用了虚拟内存管理机制(6.4节),这项技术主要具有以下两方面的优势。
- 进程与进程之间、进程与内核之间彼此隔离,因此一个进程无法读取或修改内核或其他进程的内存内容。
- 只需将进程的一部分保持在内存中,这不但降低了每个进程对内存的需求量,而且还能在RAM中同时加载更多的进程。这也大幅提升了如下事件的发生概率,在任一时刻,CPU都有至少一个进程可以执行,从而使得对CPU资源的利用更加充分。
文件系统:内核在磁盘之上提供有文件系统,允许对文件执行创建、获取、更新以及删除等操作。
创建和终止进程:内核可将新程序载入内存,为其提供运行所需的资源(比如,CPU、内存以及对文件的访问等)。这样一个运行中的程序我们称之为“进程”。一旦进程执行完毕,内核还要确保释放其占用资源,以供后续程序重新使用。
设备访问:计算机外接设备(鼠标、键盘、磁盘和磁带驱动器等)可实现计算机与外部世界的通信,这一通信机制包括输入、输出或是两者兼而有之。内核既为程序访问设备提供了简化版的标准接口,同时还要仲裁多个进程对每一个设备的访问。
联网:内核以用户进程的名义收发网络消息(数据包)。该任务包括将网络数据包路由至目标系统。
提供系统调用应用编程接口(API):进程可利用内核入口点(也称为系统调用)请求内核去执行各种任务。
除了上述特性外,一般而言,诸如Linux之类的多用户操作系统会为每个用户营造一种抽象:虚拟私有计算机(virtual private computer)。这就是说,每个用户都可以登录进入系统,独立操作,而与其他用户大致无干。例如,每个用户都有属于自己的磁盘存储空间(主目录)。再者,用户能够运行程序,而每一程序都能从CPU资源中“分得一杯羹”,运转于自有的虚拟地址空间中。而且这些程序还能独立访问设备,并通过网络传递信息。内核负责解决(多进程)访问硬件资源时可能引发的冲突,用户和进程对此则往往一无所知。
内核态和用户态
现代处理器架构一般允许CPU至少在两种不同状态下运行,即:用户态和核心态(有时也称之为监管态supervisor mode)。执行硬件指令可使CPU在两种状态间来回切换。与之对应,可将虚拟内存区域划分(标记)为用户空间部分或内核空间部分。在用户态下运行时,CPU只能访问被标记为用户空间的内存,试图访问属于内核空间的内存会引发硬件异常。当运行于核心态时,CPU既能访问用户空间内存,也能访问内核空间内存。
仅当处理器在核心态运行时,才能执行某些特定操作。这样的例子包括:执行宕机(halt)指令去关闭系统,访问内存管理硬件,以及设备I/O操作的初始化等。实现者们利用这一硬件设计,将操作系统置于内核空间。这确保了用户进程既不能访问内核指令和数据结构,也无法执行不利于系统运行的操作。
文件I/O
文件描述符
所有执行I/O 操作的系统调用都以文件描述符,一个非负整数(通常是小整数),来指代打开的文件。文件描述符用以表示所有类型的已打开文件,包括管道(pipe)、FIFO、socket、终端、设备和普通文件。针对每个进程,文件描述符都自成一套。
在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、链接文件和设备文件。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。如果此时去打开一个新的文件,它的文件描述符会是3。
UNIX I/O 模型的显著特点之一是其输入/输出的通用性概念。这意味着使用4个同样的系统调用open()、read()、write()和close()可以对所有类型的文件执行I/O 操作,包括终端之类的设备。
UNIX I/O模型的显著特点之一是其输入/输出的通用性概念。这意味着使用4个同样的系统调用open()、read()、write()和close()可以对所有类型的文件执行I/O操作,包括终端之类的设备。因此,仅使用这些系统调用编写的程序,将对任何类型的文件有效。例如,针对程序清单4-1中的程序,如下操作都是有效的:
cp test test.old #复制标准文件
cp a.txt /dev/tty #复制标准文件到终端
cp /dev/tty b.txt #复制终端的输入内容到标准文件
cp /dev/pts/16 /dev/tty #从一个终端复制到另一个终端
open()
open()调用既能打开一个业已存在的文件,也能创建并打开一个新文件。
# include <sys/stat.h>
# include <fcntl.h>
int open(const char *pathname, int flags, .../* mod_t mode */);
pathname,文件目录.
flags,打开形式,用于指定文件的访问模式.
如果调用成功,open()将返回一文件描述符,用于在后续函数调用中指代该文件。
若发生错误,则返回−1,并将 errno 置为相应的错误标志。如果调用 open()成功,必须保证其返回值为进程未用文件描述符中数值最小者。
flags:
flags | 用途 | 统一UNIX规范版本 |
---|---|---|
O_RDONLY | 以只读形式打开 | v3 |
O_WRONLY | 以只写方式打开 | v3 |
O_RDWR | 以读写方式打开 | v3 |
O_CLOEXEC | 设置close-on-exec标志 | v4 |
O_CREAT | 若文件不存在,则创建文件 | v3 |
O_DIRECT | 无缓冲的输入/输出 | |
O_DIRECTORY | 如果pathname不是目录,则失败 | v4 |
O_EXCL | 结合O_CREAT参数使用,专门用于创建文件 | v3 |
O_LARGEFILE | 在32位系统中打开大文件 | |
O_NOATIME | 调用read()时,不修改文件最近访问时间(Linux2.6.8开始) | |
O_NOCTTY | 不要让pathname成为控制终端 | v3 |
O_NOFOLLOW | 对符号链接不予解引用 | v4 |
O_TRUNC | 截断已有文件,使其长度为0 | v3 |
O_APPEND | 在文件尾部追加数据 | v3 |
O_ASYNC | 当I/O操作可行时,产生信号通知进程 | |
O_DSYNC | 提供同步的I/O数据完整性(自Linux2.6.33版本开始) | v3 |
O_NONBLOCK | 以非阻塞方式打开 | v3 |
O_SYNC | 以同步方式写入文件 | v3 |
mode_t详解:
S_IRWXU 00700 属主读、写、执行
S_IRUSR 00400 属主读
S_IWUSR 00200 属主写
S_IXUSR 00100 属主执行
S_IRWXG 00070 属组读、写、执行
S_IRGRP 00040 属组读
S_IWGRP 00020 属组写
S_IXGRP 00010 属组执行
S_IRWXO 00007 其他读、写、执行
S_IROTH 00004 其他读
S_IWOTH 00002 其他写
S_IXOTH 00001 其他执行
S_ISUID 0004000 把进程的有效用户设置为文件的所有者
S_ISGID 0002000 把进程的有效组设置为文件的组
S_ISVTX 0001000 粘着位,作用1:可执行程序执行结束后,会缓存到交换空间,由于交换空间的磁盘是连续的,不需要找block,所以可以实现快速加载程序的效果。作用2:用来标记目录,标记后的目录只有属主可以对其有写权限,其他人只能读。参考/tmp目录,所有人可读,属主可写。
常用错误码
open()函数的错误
若打开文件时发生错误,open()将返回−1,错误号errno标识错误原因。以下是一些可能发生的错误.
错误码 | 解释 |
---|---|
EACCES | 文件权限不允许以flags参数指定的方式打开文件。 |
EISDIR | 要打开的文件是一个目录,通常情况下不允许对目录进行写操作。 |
ENFILE | 文件打开数量已经达到系统允许的上限。 |
ENOENT | 文件不存在,并且未指定O_CREAT标志。 |
EROFS | 文件是只读的,调用者企图用写的方式打开。 |
ETXTBSY | 要打开的文件是正在运行的程序文件。系统不允许修改正在运行的程序。 |
errno全部错误码
#define EPERM 1 /* Operation not permitted */
#define ENOENT 2 /* No such file or directory */
#define ESRCH 3 /* No such process */
#define EINTR 4 /* Interrupted system call */
#define EIO 5 /* I/O error */
#define ENXIO 6 /* No such device or address */
#define E2BIG 7 /* Argument list too long */
#define ENOEXEC 8 /* Exec format error */
#define EBADF 9 /* Bad file number */
#define ECHILD 10 /* No child processes */
#define EAGAIN 11 /* Try again */
#define ENOMEM 12 /* Out of memory */
#define EACCES 13 /* Permission denied */
#define EFAULT 14 /* Bad address */
#define ENOTBLK 15 /* Block device required */
#define EBUSY 16 /* Device or resource busy */
#define EEXIST 17 /* File exists */
#define EXDEV 18 /* Cross-device link */
#define ENODEV 19 /* No such device */
#define ENOTDIR 20 /* Not a directory */
#define EISDIR 21 /* Is a directory */
#define EINVAL 22 /* Invalid argument */
#define ENFILE 23 /* File table overflow */
#define EMFILE 24 /* Too many open files */
#define ENOTTY 25 /* Not a typewriter */
#define ETXTBSY 26 /* Text file busy */
#define EFBIG 27 /* File too large */
#define ENOSPC 28 /* No space left on device */
#define ESPIPE 29 /* Illegal seek */
#define EROFS 30 /* Read-only file system */
#define EMLINK 31 /* Too many links */
#define EPIPE 32 /* Broken pipe */
#define EDOM 33 /* Math argument out of domain of func */
#define ERANGE 34 /* Math result not representable */
#define EDEADLK 35 /* Resource deadlock would occur */
#define ENAMETOOLONG 36 /* File name too long */
#define ENOLCK 37 /* No record locks available */
#define ENOSYS 38 /* Function not implemented */
#define ENOTEMPTY 39 /* Directory not empty */
#define ELOOP 40 /* Too many symbolic links encountered */
#define EWOULDBLOCK EAGAIN /* Operation would block */
#define ENOMSG 42 /* No message of desired type */
#define EIDRM 43 /* Identifier removed */
#define ECHRNG 44 /* Channel number out of range */
#define EL2NSYNC 45 /* Level 2 not synchronized */
#define EL3HLT 46 /* Level 3 halted */
#define EL3RST 47 /* Level 3 reset */
#define ELNRNG 48 /* Link number out of range */
#define EUNATCH 49 /* Protocol driver not attached */
#define ENOCSI 50 /* No CSI structure available */
#define EL2HLT 51 /* Level 2 halted */
#define EBADE 52 /* Invalid exchange */
#define EBADR 53 /* Invalid request descriptor */
#define EXFULL 54 /* Exchange full */
#define ENOANO 55 /* No anode */
#define EBADRQC 56 /* Invalid request code */
#define EBADSLT 57 /* Invalid slot */
#define EDEADLOCK EDEADLK
#define EBFONT 59 /* Bad font file format */
#define ENOSTR 60 /* Device not a stream */
#define ENODATA 61 /* No data available */
#define ETIME 62 /* Timer expired */
#define ENOSR 63 /* Out of streams resources */
#define ENONET 64 /* Machine is not on the network */
#define ENOPKG 65 /* Package not installed */
#define EREMOTE 66 /* Object is remote */
#define ENOLINK 67 /* Link has been severed */
#define EADV 68 /* Advertise error */
#define ESRMNT 69 /* Srmount error */
#define ECOMM 70 /* Communication error on send */
#define EPROTO 71 /* Protocol error */
#define EMULTIHOP 72 /* Multihop attempted */
#define EDOTDOT 73 /* RFS specific error */
#define EBADMSG 74 /* Not a data message */
#define EOVERFLOW 75 /* Value too large for defined data type */
#define ENOTUNIQ 76 /* Name not unique on network */
#define EBADFD 77 /* File descriptor in bad state */
#define EREMCHG 78 /* Remote address changed */
#define ELIBACC 79 /* Can not access a needed shared library */
#define ELIBBAD 80 /* Accessing a corrupted shared library */
#define ELIBSCN 81 /* .lib section in a.out corrupted */
#define ELIBMAX 82 /* Attempting to link in too many shared libraries */
#define ELIBEXEC 83 /* Cannot exec a shared library directly */
#define EILSEQ 84 /* Illegal byte sequence */
#define ERESTART 85 /* Interrupted system call should be restarted */
#define ESTRPIPE 86 /* Streams pipe error */
#define EUSERS 87 /* Too many users */
#define ENOTSOCK 88 /* Socket operation on non-socket */
#define EDESTADDRREQ 89 /* Destination address required */
#define EMSGSIZE 90 /* Message too long */
#define EPROTOTYPE 91 /* Protocol wrong type for socket */
#define ENOPROTOOPT 92 /* Protocol not available */
#define EPROTONOSUPPORT 93 /* Protocol not supported */
#define ESOCKTNOSUPPORT 94 /* Socket type not supported */
#define EOPNOTSUPP 95 /* Operation not supported on transport endpoint */
#define EPFNOSUPPORT 96 /* Protocol family not supported */
#define EAFNOSUPPORT 97 /* Address family not supported by protocol */
#define EADDRINUSE 98 /* Address already in use */
#define EADDRNOTAVAIL 99 /* Cannot assign requested address */
#define ENETDOWN 100 /* Network is down */
#define ENETUNREACH 101 /* Network is unreachable */
#define ENETRESET 102 /* Network dropped connection because of reset */
#define ECONNABORTED 103 /* Software caused connection abort */
#define ECONNRESET 104 /* Connection reset by peer */
#define ENOBUFS 105 /* No buffer space available */
#define EISCONN 106 /* Transport endpoint is already connected */
#define ENOTCONN 107 /* Transport endpoint is not connected */
#define ESHUTDOWN 108 /* Cannot send after transport endpoint shutdown */
#define ETOOMANYREFS 109 /* Too many references: cannot splice */
#define ETIMEDOUT 110 /* Connection timed out */
#define ECONNREFUSED 111 /* Connection refused */
#define EHOSTDOWN 112 /* Host is down */
#define EHOSTUNREACH 113 /* No route to host */
#define EALREADY 114 /* Operation already in progress */
#define EINPROGRESS 115 /* Operation now in progress */
#define ESTALE 116 /* Stale NFS file handle */
#define EUCLEAN 117 /* Structure needs cleaning */
#define ENOTNAM 118 /* Not a XENIX named type file */
#define ENAVAIL 119 /* No XENIX semaphores available */
#define EISNAM 120 /* Is a named type file */
#define EREMOTEIO 121 /* Remote I/O error */
#define EDQUOT 122 /* Quota exceeded */
#define ENOMEDIUM 123 /* Nomedium found */
#define EMEDIUMTYEP 124 /*Wrongmedium found */
#define ECANCELED 125 /* Operation Canceled */
#define ENOKEY 126 /* Required key not available */
#define EKEYEXPIRED 127 /* Key has expired */
#define EKEYREVOKED 128 /* Key has been revoked */
#define EKEYREJECTED 129 /* Key was rejected by service */
#define EOWNERDEAD 130 /* Owner died */
#define ENOTRECOVERABLE 131 /* State not recoverable */
#define ERFKILL 132 /* Operation not possible due to RF-kill */
#define EHWPOISON 133 /* Memory page has hardware error */
read()
read()系统调用从文件描述符 fd 所指代的打开文件中读取数据。
# include <unistd.h>
ssize_t read(int fd, void *buffer, size_t count);
fd,已打开文件的返回码。
buffer,缓存的地址。
count,一次最多能读取的字节数。
成功返回实际读取的字节数,如果遇到文件结束符(EOF)返回0。失败返回-1并设置errno。
{%y%}
read的文件结尾要有 \n
由于表示字符串终止的空字符需要一个字节的内存,所以缓冲区的大小至少要比预计读取的最大字符串长度多出1个字节。
例子:
/opt/test.txt
Hello Boss Chen!\n
#include <iostream>
#include<fcntl.h>
#include<sys/stat.h>
#include<unistd.h>
using namespace std;
int main()
{
int fd;
ssize_t num;
char ttt[]="abc\n";
cout << "Hello World!" << endl;
cout <<ttt;
fd=open("/opt/test.txt",O_RDWR);
if(fd!=-1)
{
cout<<"Open file error!"<<endl;
}
num=read(fd,ttt,3);
if(num!=-1)
{
cout<<"Read file error!"<<endl;
}
cout<<ttt;
return 0;
}
Hello Boss Chen!
abc
abc
write()
write()系统调用将数据写入一个已打开的文件中。
#include <unistd.h>
ssize_t write(int fd, void *buffer, size_t count);
fd,已打开文件的返回码。
buffer,缓存的地址。
count,要写入的字节数。
成功返回实际写入的字节数,可能会出现部分写的情况(磁盘已满或者进程资源对文件大小有限制)。
失败返回-1,并设置错误码。
close()
close()系统调用关闭一个打开的文件描述符,并将其释放回调用进程,供该进程继续使用。当一进程终止时,将自动关闭其已打开的所有文件描述符。
#include <unistd.h>
int close(int fd);
fd,已打开文件的返回码。
成功返回0。
失败返回-1并设置错误码。
检查文件有效性
检查文件有效性有两办法:
1.open()
根据open()返回码判断。
2.access()
有效返回0,无效返回非0.
lseek()
文件偏移量是指执行下一个read()或write()操作的文件起始位置,会以相对于文件头部起始点的文件当前位置来表示。文件第一个字节的偏移量为0。
# include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
fd,已打开文件的返回码。
offset,指定了一个以字节为单位的数值。
whence,表明应参照哪个基点来解释offset 参数.
成功返回偏移量,
失败返回-1并设置错误码。
whence
whence | 值 | 含义 |
---|---|---|
SEEK_SET | 0 | 相对于文件头部开始的offset个字节 |
SEEK_CUR | 1 | 相对于当前文件偏移量起始的offset个字节 |
SEEK_END | 2 | 相对于文件尾部起始的offset个字节 |
lseek例子
lseek(fd, 0, SEEK_SET) //文件头
lseek(fd, 0, SEEK_END) //文件尾
lseek(fd, -1, SEEK_END) //文件尾的前1个字节
lseek(fd, -10, SEEK_CUR) //文件当前偏移的前10个字节
lseek(fd, 10000, SEEK_END) //文件尾的后10000个字节
{%y%}
文件空洞
文件偏移量跨越了文件结尾,如 lseek(fd, 1000, SEEK_END) ,之后再进行I/O操作,read()调用会返回0,write()调用会写入数据,文件结尾到新写入数据之间的这段空间被称为文件空洞。文件空洞不占用任何磁盘空间(大多数情况下),当向文件空洞写入数据时,系统才会为其分配空间。
{%y%}
原子操作和竞争条件
原子操作:内核保证了某些系统调用会一次性执行,不会被其他进程或线程所中断,原子操作的作用是为了避免竞争条件。
竞争条件:当两个进程同时对同一个资源进行修改操作时,会产生竞争条件。
文件的原子操作
以独占方式创建一个文件
当同时指定O_EXCL与O_CREAT作为open()的标志位时,如果要打开的文件已然存在,则open()将返回一个错误。这提供了一种机制,保证进程是打开文件的创建者。对文件是否存在的检查和创建文件属于同一原子操作。
此方式是为了避免覆盖原文件。
向文件尾部追加数据
用以说明原子操作必要性的第二个例子是:多个进程同时向同一个文件(例如,全局日志文件)尾部添加数据。为了达到这一目的,需要将文件偏移量的移动与数据写操作纳入同一原子操作。在打开文件时加入O_APPEND标志就可以保证这一点。
文件控制操作:fcntl()
fcntl()系统调用对一个打开的文件描述符执行一系列控制操作。
#include<fcntl.h>
int fcntl(int fd ,int cmd , ...);
fcntl()的第三个参数以省略号来表示,这意味着可以将其设置为不同的类型,或者加以省略。内核会依据cmd参数(如果有的话)的值来确定该参数的数据类型。
cmd参数:
1.
判定文件的访问模式有一点复杂,这是因为O_RDONLY(0)、O_WRONLY(1)和O_RDWR(2)这3个常量并不与打开文件状态标志中的单个比特位对应。因此,要判定访问模式,需使用掩码O_ACCMODE与flag相与,将结果与3个常量进行比对,示例代码如下:
int flags,accessMode;
flags=fcntl(fd,F_GETFL); //第三个参数不要求
accessMode=flags & O_ACCMODE;
if(accessMode == O_WRONLY || accessMode == O_RDWR)
printf("file is writeable\n");
为了修改打开文件的状态标志,可以使用fcntl()的F_GETFL命令来获取当前标志的副本,然后修改需要变更的比特位,最后再次调用fcntl()函数的F_SETFL命令来更新此状态标志。因此,为了添加O_APPEND标志,可以编写如下代码:
int flags;
flags=fcntl(fd,F_GETFL);
if(flags == -1)
errExit("fcntl");
flags |= O_APPEND;
if(fcntl(fd,F_SETFL,flags) == -1)
errExit("fcntl");
文件描述符和打开文件之间的关系
内核对所有打开的文件维护有一个系统级的描述表格(open file description table)。有时,也称之为打开文件表(open file table),并将表中各条目称为打开文件句柄(open file handle)。一个打开文件句柄存储了与一个打开文件相关的全部信息,如下所示:
当前文件偏移量(调用read()和write()时更新,或使用lseek()直接修改)。
打开文件时所使用的状态标志(即,open()的flags参数)。
文件访问模式(如调用open()时所设置的只读模式、只写模式或读写模式)。
与信号驱动I/O相关的设置。
对该文件i-node对象的引用。
两个不同的文件描述符,若指向同一打开文件句柄,将共享同一文件偏移量。因此,如果通过其中一个文件描述符来修改文件偏移量(由调用read()、write()或lseek()所致),那么从另一文件描述符中也会观察到这一变化。无论这两个文件描述符分属于不同进程,还是同属于一个进程,情况都是如此。
要获取和修改打开的文件标志(例如,O_APPEND、O_NONBLOCK和O_ASYNC),可执行fcntl()的F_GETFL和F_SETFL操作,其对作用域的约束与上一条颇为类似。
相形之下,文件描述符标志(亦即,close-on-exec标志)为进程和文件描述符所私有。对这一标志的修改将不会影响同一进程或不同进程中的其他文件描述符。
复制文件描述符
dup()调用复制一个打开的文件描述符oldfd,并返回一个新描述符,二者都指向同一打开的文件句柄。
#include<unistd.h>
int dup(int oldfd);
Linux从2.6.24开始支持fcntl()用于复制文件描述符的附加命令:F_DUPFD_CLOEXEC。该标志不仅实现了与F_DUPFD相同的功能,还为新文件描述符设置close-on-exec标志。同样,此命令之所以得以一显身手,其原因也类似于open()调用中的O_CLOEXEC标志。SUSv3并未论及F_DUPFD_CLOEXEC标志,但SUSv4对其作了规范。
在文件特定偏移量处的I/O:pread()和pwrite()
系统调用pread()和pwrite()完成与read()和write()相类似的工作,只是前两者会在offset参数所指定的位置进行文件I/O操作,而非始于文件的当前偏移量处,且它们不会改变文件的当前偏移量。
#include<unistd.h>
ssize_t pread(int fd,void *buf,size_t count,off_t offset);
//调用成功返回字节数,文件尾返回0,调用失败返回-1
ssize_t pwite(int fd,const void *buf,size_t count,off_t offset);
//调用成功返回字节数,调用失败返回-1
如果需要反复执行lseek(),并伴之以文件I/O,那么pread()和pwrite()系统调用在某些情况下是具有性能优势的。这是因为执行单个pread()(或pwrite())系统调用的成本要低于执行lseek()和read()(或write())两个系统调用。然而,较之于执行I/O实际所需的时间,系统调用的开销就有些相形见绌了。
分散输入和集中输出
readv()和writev()系统调用分别实现了分散输入和集中输出的功能。
#include<sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov,int iovcnt);
//调用成功返回字节数,文件尾返回0,调用失败返回-1
ssize_t writev(int fd, const struct iovec *iov,int iovcnt);
这些系统调用并非只对单个缓冲区进行读写操作,而是一次即可传输多个缓冲区的数据。数组iov定义了一组用来传输数据的缓冲区。整型数iovcnt则指定了iov的成员个数。iov中的每个成员都是如下形式的数据结构:
struct iovec{
void *iov_base; //缓冲区起始地址
size_t iov_len; //转换字节数
};
例程:
/* t_readv.c
Demonstrate the use of the readv() system call to perform "gather I/O".
(This program is merely intended to provide a code snippet for the book;
unless you construct a suitably formatted input file, it can't be
usefully executed.)
*/
#include <sys/stat.h>
#include <sys/uio.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
int
main(int argc, char *argv[])
{
int fd;
struct iovec iov[3];
struct stat myStruct; /* First buffer */
int x; /* Second buffer */
#define STR_SIZE 100
char str[STR_SIZE]; /* Third buffer */
ssize_t numRead, totRequired;
if (argc != 2 || strcmp(argv[1], "--help") == 0)
printf("%s file\n", argv[0]);
fd = open(argv[1], O_RDONLY);
if (fd == -1)
printf("open");
totRequired = 0;
iov[0].iov_base = &myStruct;
iov[0].iov_len = sizeof(struct stat);
totRequired += iov[0].iov_len;
iov[1].iov_base = &x;
iov[1].iov_len = sizeof(x);
totRequired += iov[1].iov_len;
iov[2].iov_base = str;
iov[2].iov_len = STR_SIZE;
totRequired += iov[2].iov_len;
numRead = readv(fd, iov, 3);
if (numRead == -1)
printf("readv");
if (numRead < totRequired)
printf("Read fewer bytes than requested\n");
printf("total bytes requested: %ld; bytes read: %ld\n",
(long) totRequired, (long) numRead);
return 0;
}
./a.out file
openreadvRead fewer bytes than requested
total bytes requested: 248; bytes read: -1
截断文件
truncate()和ftruncate()系统调用将文件大小设置为length参数指定的值。
#include<unistd.h>
int truncate(const char *pathname,off_t length);
int ftruncate(int fd,off_t length);
//都是成功返回0,失败返回-1
若文件当前长度大于参数length,调用将丢弃超出部分,若小于参数length,调用将在文件尾部添加一系列空字节或是一个文件空洞。
两个系统调用之间的差别在于如何指定操作文件。truncate()以路径名字符串来指定文件,并要求可访问该文件,且对文件拥有写权限。若文件名为符号链接,那么调用将对其进行解引用。而调用ftruncate()之前,需以可写方式打开操作文件,获取其文件描述符以指代该文件,该系统调用不会修改文件偏移量。
非阻塞I/O
在打开文件时指定O_NONBLOCK标志,目的有二。
若open()调用未能立即打开文件,则返回错误,而非陷入阻塞。有一种情况属于例外,调用open()操作FIFO可能会陷入阻塞。
调用open()成功后,后续的I/O操作也是非阻塞的。若I/O系统调用未能立即完成,则可能会只传输部分数据,或者系统调用失败,并返回EAGAIN或EWOULDBLOCK错误。具体返回何种错误将依
赖于系统调用。Linux系统与许多UNIX实现一样,将两个错误常量视为同义。
管道、FIFO、套接字、设备(比如终端、伪终端)都支持非阻塞模式。(因为无法通过 open()来获取管道和套接字的文件描述符,所以要启用非阻塞标志,就必须使用5.3节所述fcntl()的F_SETFL命令。)
由于内核缓冲区保证了普通文件I/O不会陷入阻塞,故而打开普通文件时一般会忽略O_NONBLOCK标志。然而,当使用强制文件锁时,O_NONBLOCK标志对普通文件也是起作用的。
大文件I/O
通常将存放文件偏移量的数据类型 off_t 实现为一个有符号的长整型。(之所以采用有符号数据类型,是要以−1来表示错误情况。)在32位体系架构中(比如x86-32),这将文件大小置于(2^31)−1个字节(即2GB)的限制之下。
然而,磁盘驱动器的容量早已超出这一限制,因此32位UNIX实现有处理超过2GB大小文件的需求,这也在情理之中。由于问题较为普遍,UNIX厂商联盟在大型文件峰会(Large File Summit)上就此进行了协商,并针对必需的大文件访问功能,形成了对SUSv2规范的扩展。
始于内核版本2.4,32位Linux系统开始提供对LFS的支持(glibc版本必须为2.2或更高)。另一个前提是,相应的文件系统也必须支持大文件操作。大多数“原生”Linux文件系统提供了LFS支持,但一些“非原生”文件系统则未提供该功能(微软的 VFAT 和NFSv2系统是其中较为知名的范例,无论系统是否启用了LFS扩展功能,2GB的文件大小限制都是硬杠杠)。
应用程序可使用如下两种方式之一以获得LFS功能。
1.使用支持大文件操作的备选API。该API由LFS设计,意在作为SUS规范的“过渡型扩展”。因此,尽管大部分系统都支持这一API,但这对于符合SUSv2或SUSv3规范的系统其实并非必须。这一方法现已过时。
2.在编译应用程序时,将宏_FILE_OFFSET_BITS的值定义为64。这一方法更为可取,因为符合SUS规范的应用程序无需修改任何源码即可获得LFS功能。
将宏_FILE_OFFSET_BITS的值定义为64。做法之一是利用C语言编译器的命令行选项:
cc -D_FILE_OFFSET_BIT=64 pro.c
另外一种方法,是在C语言的源文件中,在包含所有头文件之前添加如下宏定义:
#define _FILE_OFFSET_BIT 64
创建临时文件
有些程序需要创建一些临时文件,仅供其在运行期间使用,程序终止后即行删除。例如,很多编译器程序会在编译过程中创建临时文件。GNU C语言函数库为此而提供了一系列库函数。(之所以有“一系列”的库函数,部分原因是由于这些函数分别继承自各种UNIX实现。)本节将介绍其中的两个函数:mkstemp()和tmpfile()。
基于调用者提供的模板,mkstemp()函数生成一个唯一文件名并打开该文件,返回一个可用于I/O调用的文件描述符。
#include<stdlib.h>
int mkstemp(char *template);
//成功返回文件描述符,失败返回-1
模板参数采用路径名形式,其中最后6个字符必须为XXXXXX。这6个字符将被替换,以保证文件名的唯一性,且修改后的字符串将通过template参数传回。因为会对传入的template参数进行修改,所以必须将其指定为字符数组,而非字符串常量。
文件拥有者对mkstemp()函数建立的文件拥有读写权限(其他用户则没有任何操作权限),且打开文件时使用了O_EXCL标志,以保证调用者以独占方式访问文件。
通常,打开临时文件不久,程序就会使用unlink系统调用将其删除。
int fd;
char template[]="/tmp/somestringXXXXXX";
fd=mkstemp(template);
if(fd == -1)
printf("mkstemp");
printf("Generated filrname was:%s\n",template);
unlink(template);
if(close(fd)==-1)
printf("close");
tmpfile()函数会创建一个名称唯一的临时文件,并以读写方式将其打开。(打开该文件时使用了O_EXCL标志,以防一个可能性极小的冲突,即另一个进程已经创建了一个同名文件。)
#include<stdio.h>
FILE *tmpfile(void);
//成功返回文件指针,失败返回NULL
tmpfile()函数执行成功,将返回一个文件流供stdio库函数使用。文件流关闭后将自动删除临时文件。为达到这一目的,tmpfile()函数会在打开文件后,从内部立即调用unlink()来删除该文件名.
文件I/O缓冲
出于速度和效率考虑,系统I/O调用(即内核)和标准C语言库I/O函数(即stdio函数)在操作磁盘文件时会对数据进行缓冲。
文件I/O的内核缓冲:缓冲区高速缓存
read()和write()系统调用在操作磁盘文件时不会直接发起磁盘访问,而是仅仅在用户空间缓冲区与内核缓冲区高速缓存(kernel buffer cache)之间复制数据。例如,如下调用将3个字节的数据从用户空间内存传递到内核空间的缓冲区中:
write(fd,"abc",3);
write()随即返回。在后续某个时刻,内核会将其缓冲区中的数据写入(刷新至)磁盘。(因此,可以说系统调用与磁盘操作并不同步。)如果在此期间,另一进程试图读取该文件的这几个字节,那么内核将自动从缓冲区高速缓存中提供这些数据,而不是从文件中(读取过期的内容)。
与此同理,对输入而言,内核从磁盘中读取数据并存储到内核缓冲区中。read()调用将从该缓冲区中读取数据,直至把缓冲区中的数据取完,这时,内核会将文件的下一段内容读入缓冲区高速缓存。
采用这一设计,意在使read()和write()调用的操作更为快速,因为它们不需要等待(缓慢的)磁盘操作。同时,这一设计也极为高效,因为这减少了内核必须执行的磁盘传输次数。
Linux内核对缓冲区高速缓存的大小没有固定上限。内核会分配尽可能多的缓冲区高速缓存页,而仅受限于两个因素:可用的物理内存总量,以及出于其他目的对物理内存的需求(例如,需要将正在运行进程的文本和数据页保留在物理内存中)。若可用内存不足,则内核会将一些修改过的缓冲区高速缓存页内容刷新到磁盘,并释放其供系统重用。
更确切地说,从内核2.4开始,Linux 不再维护一个单独的缓冲区高速缓存。相反,会将文件I/O缓冲区置于页面高速缓存中,其中还含有诸如内存映射文件的页面。然而,正文的讨论采用了“缓冲区高速缓存(buffer cache)”这一术语,因为这是UNIX 实现中历史悠久的通称。
无论是让磁盘写1000次,每次写入一个字节,还是一次写入1000个字节,内核访问磁盘的字节数都是相同的。然而,我们更属意于后者,因为它只需要一次系统调用,而前者则需要调用1000次。尽管比磁盘操作要快许多,但系统调用所耗费的时间总量也相当可观:内核必须捕获调用,检查系统调用参数的有效性,在用户空间和内核空间之间传输数据.
所谓普通内核(vanilla kernel),意指未打补丁的主线(mainline)内核。与之形成鲜明对比的是大多数发行商所提供的内核,常包含各种补丁来修复错误和添加新功能。
因为采用不同的缓冲区大小时,数据的传输总量(因此招致磁盘操作的数量)是相同的,表13-1所示为发起read()和write()调用的开销。缓冲区大小为1字节时,需要调用read()和write()1亿次,缓冲区大小为4096个字节时,需要调用read()和write() 24000次左右,几乎达到最优性能。设置再超过这个值,对性能的提升就不显著了,这是因为与在用户空间和内核空间之间复制数据以及执行实际磁盘I/O 所花费的时间相比,read()和write() 系统调用的成本就显得微不足道了。
总之,如果与文件发生大量的数据传输,通过采用大块空间缓冲数据,以及执行更少的系统调用,可以极大地提高I / O 性能。
stdio库的缓冲
当操作磁盘文件时,缓冲大块数据以减少系统调用,C语言函数库的I/O函数(比如,fprintf()、fscanf()、fgets()、fputs()、fputc()、fgetc())正是这么做的。因此,使用stdio库可以使编程者免于自行处理对数据的缓冲,无论是调用 write()来输出,还是调用 read()来输入。
调用setvbuf()函数,可以控制stdio库使用缓冲的形式。
#include<stdio.h>
int setvbuf(FILE *stream, char *buffer, int mode, size_t size)
数stream标识将要修改哪个文件流的缓冲。打开流后,必须在调用任何其他stdio函数之前先调用setvbuf()。setvbuf()调用将影响后续在指定流上进行的所有stdio操作。
参数buf和size则针对参数stream要使用的缓冲区.
参数mode指定了缓冲类型,并具有下列值之一。
_IONBF
不对I/O进行缓冲。每个stdio库函数将立即调用write()或者read(),并且忽略buf和size参数,可以分别指定两个参数为NULL和0。stderr默认属于这一类型,从而保证错误能立即输出。
_IOLBF
采用行缓冲I/O。指代终端设备的流默认属于这一类型。对于输出流,在输出一个换行符(除非缓冲区已经填满)前将缓冲数据。对于输入流,每次读取一行数据。
_IOFBF
采用全缓冲I/O。单次读、写数据(通过read()或write()系统调用)的大小与缓冲区相同。指代磁盘的流默认采用此模式。
例子:
#define BUF_SIZE 1024
static char buf[BUF_SIZE];
if(setvbuf(stdout,buf,_IOFBF,BUF_SIZE)!=0)
printf("error");
注意:setvbuf()出错时返回非0值(而不一定是−1)。
setbuf()函数构建于setvbuf()之上,执行了类似任务。
#include<stdio.h>
void setbuf(FILE *steam, char *buf);
setbuffer()函数类似于setbuf()函数,但允许调用者指定buf缓冲区大小。
#include<stdio.h>
void setbuffer(FILE * stream,char * buf,size_t size);
对setbuffer(fp,buf,size)的调用相当于如下调用:
setvbuf(fp,buf,(buf != NULL)? _IOFBF:_IONBF,size)
SUSv3并未对setbuffer()函数加以定义,但大多数UNIX实现均支持它。
刷新stdio缓冲区
无论当前采用何种缓冲区模式,在任何时候,都可以使用 fflush()库函数强制将 stdio输出流中的数据(即通过write())刷新到内核缓冲区中。此函数会刷新指定stream的输出缓冲区。
#include<stdio.h>
void fflush(FILE *steam);
若参数stream为NULL,则fflush()将刷新所有的stdio缓冲区。
也能将 fflush()函数应用于输入流,这将丢弃业已缓冲的输入数据。(当程序下一次尝试从流中读取数据时,将重新装满缓冲区。)
当关闭相应流时,将自动刷新其stdio缓冲区。
在包括glibc库在内的许多C函数库实现中,若stdin和stdout指向一终端,那么无论何时从stdin中读取输入时,都将隐含调用一次fflush(stdout)函数。这将刷新写入stdout的任何提示,但不包括终止换行符(比如,printf("Date:"))。然而,SUSv3和C99并未规定这一行为,也并非所有的C语言函数库都实现了这一行为。要保证程序的可移植性,应用应使用显式的fflush(stdout)调用来确保显示这些提示。
若打开一个流同时用于输入和输出,则C99标准中提出了两项要求。首先,一个输出操作不能紧跟一个输入操作,必须在二者之间调用fflush()函数或是一个文件定位函数(fseek()、fsetpos()或者rewind())。其次,一个输入操作不能紧跟一个输出操作,必须在二者之间调用一个文件定位函数,除非输入操作遭遇文件结尾。
控制文件I/O的内核缓冲
强制刷新内核缓冲区到输出文件是可能的。这有时很有必要,例如,当应用程序(诸如数据库的日志进程)要确保在继续操作前将输出真正写入磁盘(或者至少写入磁盘的硬件高速缓存中)。
同步I/O数据完整性和同步I/O文件完整性
SUSv3将同步I/O完成定义为:某一I/O操作,要么已成功完成到磁盘的数据传递,要么被诊断为不成功。
SUSv3定义了两种不同类型的同步I/O完成,二者之间的区别涉及用于描述文件的元数据(关于数据的数据),亦即内核针对文件而存储的数据。描述文件i-node时将详细讨论文件的元数据,但就目前而言,了解文件元数据包含了些什么,诸如文件属主、属组、文件权限、文件大小、文件(硬)链接数量,表明文件最近访问、修改以及元数据发生变化的时间戳,指向文件数据块的指针,就足够了。
SUSv3定义的第一种同步I/O完成类型是synchronized I/O data integrity completion,旨在确保针对文件的一次更新传递了足够的信息(到磁盘),以便于之后对数据的获取。
- 就读操作而言,这意味着被请求的文件数据已经(从磁盘)传递给进程。若存在任何影响到所请求数据的挂起写操作,那么在执行读操作之前,会将这些数据传递到磁盘。
- 就写操作而言,这意味着写请求所指定的数据已传递(至磁盘)完毕,且用于获取数据的所有文件元数据也已传递(至磁盘)完毕。此处的要点在于要获取文件数据,并非需要传递所有经过修改的文件元数据属性。发生修改的文件元数据中需要传递的属性之一是文件大小(如果写操作确实扩展了文件)。相形之下,如果是文件时间戳发生了变化,就无需在下次获取数据前将其传递到磁盘。
Synchronized I/O file integrity completion是SUSv3定义的另一种同步I/O完成,也是上述synchronized I/O data integrity completion的超集。该I/O完成模式的区别在于在对文件的一次更新过程中,要将所有发生更新的文件元数据都传递到磁盘上,即使有些在后续对文件数据的读操作中并不需要。
fsync()系统调用将使缓冲数据和与打开文件描述符fd相关的所有元数据都刷新到磁盘上。调用fsync()会强制使文件处于Synchronized I/O file integrity completion状态。
#include <unistd.h>
int fsync(int fd);
仅在对磁盘设备(或者至少是其高速缓存)的传递完成后,fsync()调用才会返回。
fdatasync()系统调用的运作类似于 fsync(),只是强制文件处于synchronized I/O data integrity completion的状态。
#include <unistd.h>
int fdatasync(int fd);
fdatasync只刷新数据到磁盘。
fsync同时刷新数据和inode信息到磁盘.
fdatasync()可能会减少对磁盘操作的次数,由fsync()调用请求的两次变为一次。例如,若修改了文件数据,而文件大小不变,那么调用fdatasync()只强制进行了数据更新。
相比之下,fsync()调用会强制将元数据传递到磁盘上。
sync()系统调用会使包含更新文件信息的所有内核缓冲区(即数据块、指针块、元数据等)刷新到磁盘上。
#include <unistd.h>
void sync(void);
若内容发生变化的内核缓冲区在30秒内未经显式方式同步到磁盘上,则一条长期运行的内核线程会确保将其刷新到磁盘上。这一做法是为了规避缓冲区与相关磁盘文件内容长期处于不一致状态(以至于在系统崩溃时发生数据丢失)的问题。
使所有写入同步:O_SYNC
调用open()函数时如指定O_SYNC标志,则会使所有后续输出同步(synchronous)。
fd=open(pathname,O_WRONLY | O_SYNC);
调用 open()后,每个 write()调用会自动将文件数据和元数据刷新到磁盘上(即,按照Synchronized I/O file integrity completion的要求执行写操作)。
早期BSD系统曾使用O_FSYNC标志来提供O_SYNC标志的功能。在glibc库中,将O_FSYNC定义为与O_SYNC标志同义。
采用O_SYNC标志(或者频繁调用fsync()、fdatasync()或sync())对性能的影响极大。
O_DSYNC和O_RSYNC标志
SUSv3规定了两个与同步I/O有关的、更为细化的打开文件状态标志:O_DSYNC 和 O_RSYNC。
O_DSYNC标志要求写操作按照synchronized I/O data integrity completion来执行(类似于fdatasync())。与之相映成趣的是O_SYNC标志,遵从synchronized I/O file integrity completion(类似于fsync()函数)。
O_RSYNC标志是与O_SYNC标志或O_DSYNC标志配合一起使用的,将这些标志对写操作的作用结合到读操作中。如果在打开文件时同时指定O_RSYNC 和O_DSYNC标志,那么就意味着会遵照synchronized I/O data integrity completion的要求来完成所有后续读操作(即,在执行读操作之前,像执行O_DSYNC标志一样完成所有待处理的写操作)。而在打开文件时指定O_RSYNC 和 O_SYNC标志,则意味着会遵照synchronized I/O file integrity completion的要求来完成所有后续读操作(即,在执行读操作之前,像执行O_SYNC标志一样完成所有待处理的写操作)。
I/O缓冲小结
下图概括了stdio函数库和内核所采用的缓冲(针对输出文件),以及对各种缓冲类型的控制机制。从图中自上而下,首先是通过stdio库将用户数据传递到stdio缓冲区,该缓冲区位于用户态内存区。当缓冲区填满时,stdio库会调用write()系统调用,将数据传递到内核高速缓冲区(位于内核态内存区)。最终,内核发起磁盘操作,将数据传递到磁盘。
就I/O模式向内核提出建议
posix_fadvise()系统调用允许进程就自身访问文件数据时可能采取的模式通知内核。
#define _XOPEN_SOURCE 600
#include<fcntl.h>
int posix_fadvise(int fd,off_t offset, off_t len, int advice);
//成功返回0,失败返回一个正数
略。
绕过缓冲区高速缓存:直接I/O
始于内核2.4,Linux允许应用程序在执行磁盘I/O时绕过缓冲区高速缓存,从用户空间直接将数据传递到文件或磁盘设备。有时也称此为直接I/O(direct I/O)或者裸I/O(raw I/O)。
此处的描述细节为Linux所特有,SUSv3并未对其进行规范。尽管如此,大多数UNIX实现均对设备和文件提供了某种形式的直接I/O访问。
有时会将直接I/O误认为获取快速I/O性能的一种手段。然而,对于大多数应用而言,使用直接I/O可能会大大降低性能。这是因为为了提高I/O性能,内核针对缓冲区高速缓存做了不少优化,其中包括:按顺序预读取,在成簇(clusters)磁盘块上执行I/O,允许访问同一文件的多个进程共享高速缓存的缓冲区。应用如使用了直接I/O将无法受益于这些优化举措。直接I/O只适用于有特定I/O需求的应用。例如数据库系统,其高速缓存和I/O优化机制均自成一体,无需内核消耗CPU时间和内存去完成相同任务。
可针对一个单独文件或块设备(比如,一块磁盘)执行直接I/O。要做到这点,需要在调用open()打开文件或设备时指定O_DIRECT标志。
O_DIRECT标志自内核2.4.10开始有效,并非所有Linux文件系统和内核版本都支持该标志。绝大多数原生(native)文件系统都支持O_DIRECT,但许多非UNIX文件系统(比如VFAT)则不支持。对于所关注的文件系统,有必要进行相关测试(若文件系统不支持O_DIRECT,则open()将失败并返回错误号EINVAL)或是阅读内核源码,以此来加以验证。
若一进程以O_DIRECT标志打开某文件,而另一进程以普通方式(即使用了高速缓存缓冲区)打开同一文件,则由直接I/O所读写的数据与缓冲区高速缓存中内容之间不存在一致性。应尽量避免这一场景。
因为直接I/O(针对磁盘设备和文件)涉及对磁盘的直接访问,所以在执行I/O时,必须遵守一些限制。
- 用于传递数据的缓冲区,其内存边界必须对齐为块大小的整数倍。
- 数据传输的开始点,亦即文件和设备的偏移量,必须是块大小的整数倍。
- 待传递数据的长度必须是块大小的整数倍。
不遵守上述任一限制均将导致EINVAL错误。在上述列表中,块大小(block size)指设备的物理块大小(通常为512字节)。
混合使用库函数和系统调用进行文件I/O
在同一文件上执行I/O操作时,还可以将系统调用和标准C语言库函数混合使用。fileno()和fdopen()函数有助于完成这一工作。
#include<stdio.h>
int fileno(FILE *stream);
FILE *fdopen(int fildes,const char * mode);
给定一个(文件)流,fileno()函数将返回相应的文件描述符(即stdio库在该流上已经打开的文件描述符)。随即可以在诸如read()、write()、dup()和fcntl()之类的I/O系统调用中正常使用该文件描述符。
fdopen()函数与 fileno()函数的功能相反。给定一个文件描述符,该函数将创建了一个使用该描述符进行文件I/O的相应流。mode参数与fopen()函数中mode参数含义相同。例如,r为读,w为写,a为追加。若该参数与文件描述符fd的访问模式不一致,则对fdopen()的调用将失败。
当使用stdio库函数,并结合系统I/O调用来实现对磁盘文件的I/O操作时,必须将缓冲问题牢记于心。I/O系统调用会直接将数据传递到内核缓冲区高速缓存,而stdio库函数会等到用户空间的流缓冲区填满,再调用write()将其传递到内核缓冲区高速缓存。请考虑如下向标准输出写入的代码:
printf("111");
write(STDOUT_FILENO,"222",4);
通常情况下,printf()函数的输出往往在 write()函数的输出之后出现。因此,代码产生如下输出:
222
111
将I/O系统调用和stdio函数混合使用时,使用fflush()来规避这一问题,是明智之举。也可以使用setvbuf()或setbuf()使缓冲区失效,但这样做可能会影响应用的I/O性能,因为每个输出操作将引起一次write()系统调用。
文件属性
获取文件信息:stat()
利用系统调用stat()、lstat()以及fstat(),可获取与文件有关的信息,其中大部分提取自文件i节点。
#include <sys/stat.h>
int stat(const char *pathname, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);
int fstat(int fd, struct stat *statbuf);
struct stat
{
dev_t st_dev; /* ID of device containing file */ //文件使用的设备号
ino_t st_ino; /* inode number */ //索引节点号
mode_t st_mode; /* protection */ //文件对应的模式,文件,目录等
nlink_t st_nlink; /* number of hard links */ //文件的硬连接数
uid_t st_uid; /* user ID of owner */ //所有者用户识别号
gid_t st_gid; /* group ID of owner */ //组识别号
dev_t st_rdev; /* device ID (if special file) */ //设备文件的设备号
off_t st_size; /* total size, in bytes */ //以字节为单位的文件容量
blksize_t st_blksize; /* blocksize for file system I/O */ //包含该文件的磁盘块的大小
blkcnt_t st_blocks; /* number of 512B blocks allocated */ //该文件所占的磁盘块
time_t st_atime; /* time of last access */ //最后一次访问该文件的时间
time_t st_mtime; /* time of last modification */ //最后一次修改该文件的时间
time_t st_ctime; /* time of last status change */ //最后一次改变该文件状态的时间
};
以上3个系统调用之间仅有的区别在于对文件的描述方式不同。
- stat()会返回所命名文件的相关信息。
- lstat()与stat()类似,区别在于如果文件属于符号链接,那么所返回的信息针对的是符号链接自身(而非符号链接所指向的文件)。
- fstat()则会返回由某个打开文件描述符所指代文件的相关信息。
文件时间戳
使用utime()或与之相关的系统调用集之一,可显式改变存储于文件i节点中的文件上次访问时间戳和上次修改时间戳。解压文件时,tar(1)和unzip(1)之类的程序会使用这些系统调用去重置文件的时间戳。
#include<utime.h>
int utime(const char *pathname,const struct utimbuf *buf);
struct utimbuf{
time_t actime; //上次修改时间
time_t modtime; //修改时间
}; //该结构中的字段记录了自Epoch(见10.1节)以来的秒数。
参数pathname用来标识欲修改时间的文件。若该参数为符号链接,则会进一步解除引用。参数buf既可为NULL,也可为指向utimbuf结构的指针。
utime()的运作方式则视以下两种不同情况而定。
- 如果buf为NULL,那么会将文件的上次访问和修改时间同时置为当前时间。这时,进程要么具有特权级别(CAP_FOWNER或CAP_DAC_OVERRIDE),要么其有效用户ID与该文件的用户ID(属主)相匹配,且对文件有写权限(逻辑上,对文件拥有写权限的进程在调用其他系统调用时,可能会于无意间改变这些时间戳)。(准确地说,如9.5节所述,在Linux系统中,用来与文件用户ID做比对的是进程的文件系统用户ID,而非其有效用户ID。)
- 若将buf指定为指向utimbuf结构的指针,则会使用该结构的相应字段去更新文件的上次访问和修改时间。此时,要么调用程序具有特权级别(CAP_FOWNER),要么进程的有效用户ID必需匹配文件的用户ID(仅对文件拥有写权限是不够的)。
inux还提供了源于BSD的utimes()系统调用,其功用类似于utime()。
#include<sys/time.h>
int utimes(const char *pathname,const struct timeval tv[2]);
utime()与 utimes()之间最显著的差别在于后者可以以微秒级精度来指定时间值.
futimes()和lutimes()库函数的功能与utimes()大同小异。前两者与后者之间的差异在于,用来指定要更改时间戳文件的参数不同。
#include<sys/time.h>
int futimes(int fd,const struct timeval tv[2]);
int lutimes(const char *pathname,const struct timeval tv[2]);
调用futimes()时,使用打开文件描述符fd来指定文件。
调用lutimes()时,使用路径名来指定文件,有别于调用utimes()的是:对于lutimes(),若路径名指向一符号链接,则调用不会对该链接进行解引用,而是更改链接自身的时间戳。
utimensat()系统调用会把由pathname指定文件的时间戳更新为由数组times指定的值。
#include <sys/stat.h>
int utimensat(int dirfd, const char *pathname,const struct timespectimes[2], intflags);
int futimens(int fd, const struct timespectimes[2]);
文件属主
每个文件都有一个与之关联的用户ID(UID)和组ID(GID),籍此可以判定文件的属主和属组。
改变文件属主
系统调用chown()、lchown()和fchown()可用来改变文件的属主(用户ID)和属组(组ID)。
#include<unistd.h>
int chown(const char *pathname,uid_t owner,gid_t group);
#define _XOPEN_SOURCE 500
#include<unistd.h>
int lchown(const char *pathname , uid_t owner,gid_t group);
int fchown(int fd,uid_t owner,gid_t group);
- chown()改变由pathname参数命名文件的所有权。
- lchown()用途与chown()相同,不同之处在于若参数pathname为一符号链接,则将会改变链接文件本身的所有权,而与该链接所指代的文件无干。
- fchown()也会改变文件的所有权,只是文件由打开文件描述符fd所引用。
文件权限
普通文件的权限
文件权限掩码分为3类。
- Owner(亦称为user):授予文件属主的权限。
- Group:授予文件属组成员用户的权限。
- Other:授予其他用户的权限。
可为每一类用户授予的权限如下所示。
- Read:可阅读文件的内容。
- Write:可更改文件的内容。
- Execute:可以执行文件(亦即,文件是程序或脚本)。要执行脚本文件(比如,一个bash脚本),需同时具备读权限和执行权限。
头文件<sys/stat.h>
定义了可与stat结构中st_mode相与(&)的常量,用于检查特定权限位置位与否。
目录权限
目录与文件拥有相同的权限方案,只是对3种权限的含义另有所指。
- 读权限:可列出(比如,通过ls命令)目录之下的内容(即目录下的文件名)。
- 写权限:可在目录内创建、删除文件。注意,要删除文件,对文件本身无需有任何权限。
- 可执行权限:可访问目录中的文件。因此,有时也将对目录的执行权限称为search(搜索)权限。
更改文件权限:chmod()和fchmod()
可利用系统调用chmod()和fchmod()去修改文件权限。
目录与链接
更改文件名:rename()
借助于rename()系统调用,既可以重命名文件,又可以将文件移至同一文件系统中的另一目录。
#include<stdio.h>
int rename(const char *oldpath,const char *newpath);
调用会将现有的一个路径名oldpath重命名为newpath参数所指定的路径名。
rename()调用仅操作目录条目,而不移动文件数据。改名既不影响指向该文件的其他硬链接,也不影响持有该文件打开描述符的任何进程,因为这些文件描述符指向的是打开文件描述,(在调用open()之后)与文件名并无瓜葛。
以下规则适用与对rename()的调用。
- 若newpath已经存在,则将其覆盖。
- 若newpath与oldpath指向同一文件,则不发生变化(且调用成功)。
使用符号链接:symlink()和readlink()
现在来看看用于创建符号链接,以及检查其内容的系统调用。
symlink()系统调用会针对由filepath所指定的路径名创建一个新的符号链接——linkpath。(想移除符号链接,需使用unlink()调用。)
#include<unistd.h>
int symlink(const char *filepath,const char *linkpath);
若linkpath中给定的路径名已然存在,则调用失败(且将errno置为EEXIST)。由filepath指定的路径名可以是绝对路径,也可以是相对路径。
如果指定一符号链接作为open()调用的pathname参数,那么将打开链接指向的文件。有时,倒宁愿获取链接本身的内容,即其所指向的路径名。这正是readlink()系统调用的本职工作,将符号链接字符串的一份副本置于buffer指向的字符数组中。
#include<unistd.h>
int readlink(const char *pathname,char *buffer,size_t bufsize);
bufsiz是一个整型参数,用以告知readlink()调用buffer中的可用字节数。
如果一切顺利,readlink()将返回实际放入buffer中的字节数。若链接长度超过bufsiz,则置于buffer中的是经截断处理的字符串(并返回字符串大小,亦即bufsiz)。
创建和移除目录:mkdir()和rmdir()
mkdir()系统调用创建一个新目录。
#include<sys/stat.h>
int mkdir(const char* pathname,mode_t mode);
pathname参数指定了新目录的路径名。该路径名可以是相对路径,也可以是绝对路径。若具有该路径名的文件已经存在,则调用失败并将errno置为EEXIST。
mode参数指定了新目录的权限。对该位掩码值的指定方式既可以与open()调用相同,也可直接赋予八进制数值。
mkdir()系统调用所创建的仅仅是路径名中的最后一部分。换言之,mkdir("aaa/bbb/ccc",mode)仅当目录aaa和aaa/bbb已经存在的情况下才会成功。
rmdir()系统调用移除由pathname指定的目录,该目录可以是绝对路径名,也可以是相对路径名。
#include<sys/stat.h>
int rmdir(const char* pathname);
要使rmdir()调用成功,则要删除的目录必须为空。如果pathname的最后一部分为符号链接,那么rmdir()调用将不对其进行解引用操作,并返回错误,同时将errno置为ENOTDIR。
移除一个文件或目录:remove()
remove()库函数移除一个文件或一个空目录。
#include<sys/stat.h>
int remove(const char* pathname);
进程的当前工作目录
一个进程的当前工作目录(current working directory)定义了该进程解析相对路径名的起点。新进程的当前工作目录继承自其父进程。
进程可使用getcwd()来获取当前工作目录。
#include<unistd.h>
char *getcwd(char *cwdbuf,size_t size);
监控文件事件
当一组受监控的文件或目录有事件发生(对文件的打开、关闭、创建、删除、修改以及重命名等操作)时,Linux专有的inotify机制可让应用程序获得通知。inotify机制取代了较老的dnotify机制。
inotify API
inotify_init()系统调用可创建一新的inotify实例。
#include<sys/inotify.h>
int inotify_init(void);
//成功返回文件描述符,失败返回-1
作为函数结果,inotify_init()会返回一个文件描述符(句柄),用于在后续操作中指代此inotify实例。
针对文件描述符fd所指代inotify实例的监控列表,系统调用inotify_add_watch()既可以追加新的监控项,也可以修改现有监控项。
#include<sys/inotify.h>
int inotify_add_watch(int fd,const char* pathname,uint32_t mask);
//成功返回观察描述符,失败返回-1
参数pathname标识欲创建或修改的监控项所对应的文件。调用程序必须对该文件具有读权限(调用inotify_add_watch()时,会对文件权限做一次性检查。只要监控项继续存在,即便有人更改了文件权限,使调用程序不再对文件具有读权限,调用程序依然会继续收到文件的通知消息)。
参数mask为一位掩码,针对pathname定义了意欲监控的事件。
mask | 事件 |
---|---|
IN_ACCESS | 文件被访问 |
IN_ATTRIB | 文件元数据改变 |
IN_CLOSE_WRITE | 关闭为了写入而打开的文件 |
IN_CREATE | 在受监控目录下创建了文件或目录 |
IN_DELETE | 在受监控目录内删除了文件或目录 |
IN_DELETE_SELF | 删除了受监控目录/文件本身 |
IN_MODIFY | 文件被修改 |
IN_MODIFY_SELF | 移动受监控目录或文件本身 |
IN_MOVED_FROM | 文件移除受监控目录 |
IN_MOVED_TO | 将文件移到受监控目录 |
IN_OPEN | 文件被打开 |
IN_ALL_EVENTS | 以上所有输出事件的统称 |
IN_MOVE | IN_MOVED_FROM |
IN_ONESHOT | 只监控pathname的一个事件 |
IN_ONLYDIR | pathname不为目录时会失败 |
IN_IGNORED | 监控项为内核或应用程序所移除 |
IN_ISDIR | name中所返回的文件名为路径 |
IN_Q_OVERFLOW | 事件队列溢出 |
IN_UNMOUNT | 包含对象的文件系统遭卸载 |
系统调用inotify_rm_watch()会从文件描述符fd所指代的inotify实例中,删除由wd所定义的监控项。
#include<sys/inotify.h>
int inotify_rm_watch(int fd,uint32_t wd);
//成功返回观察描述符,失败返回-1
参数wd是一监控描述符,由之前对inotify_add_watch()的调用返回。(uint32_t数据类型为一无符号32位整数。)
读取inotify事件
事件发生后,每次调用read()会返回一个缓冲区,内含一个或多个如下类型的结构:
struct inotify_event {
int wd; /*watch描述符 */
uint32_t mask; /* 事件掩码 */
uint32_t cookie;
uint32_t len; /* name的长度 */
char name[]; /* 文件或目录名 */
};
系统编程概念
设备专用文件(设备文件)
设备专用文件与系统的某个设备相对应。在内核中,每种设备类型都有与之相对应的设备驱动程序,用来处理设备的所有I/O请求。设备驱动程序属内核代码单元,可执行一系列操作,(通常)与相关硬件的输入/输出动作相对应。由设备驱动程序提供的API是固定的,包含的操作对应于系统调用open()、close()、read()、write()、mmap()以及ioctl()。每个设备驱动程序所提供的接口一致,这隐藏了每个设备在操作方面的差异,从而满足了I/O操作的通用性.
某些设备是实际存在的,比如鼠标、磁盘和磁带设备。而另一些设备则是虚拟的,亦即并不存在相应硬件,但内核会(通过设备驱动程序)提供一种抽象设备,其所携带的API与真实设备一般无异。
可将设备划分为以下两种类型。
- 字符型设备基于每个字符来处理数据。终端和键盘都属于字符型设备。
- 块设备则每次处理一块数据。块的大小取决于设备类型,但通常为512字节的倍数。磁盘和磁带设备都属于块设备。
与其他类型的文件一样,设备文件总会出现在文件系统中,通常位于/dev目录下。超级用户可使用mknod命令创建设备文件,特权级程序(CAP_MKNOD)执行mknod()系统调用亦可完成相同任务。
在Linux的早期版本中,/dev包含了系统中所有可能设备的条目,即使某些设备实际并未与系统连接。这意味着/dev会包含数以千计的未用设备项,从而导致了两个缺点:其一,对于需要扫描该目录内容的应用而言,降低了程序的执行速度;其二,根据该目录下的内容无法发现系统中实际存在哪些设备。Linux2.6运用udev程序解决了上述问题。该程序所依赖的sysfs文件系统,是装载于/sys下的伪文件系统,将设备和其他内核对象的相关信息导出至用户空间。
设备ID
每个设备文件都有主、辅ID号各一。主ID号标识一般的设备等级,内核会使用主ID号查找与该类设备相应的驱动程序。辅 ID 号能够在一般等级中唯一标识特定设备。命令ls –l可显示出设备文件的主、辅ID。
设备文件的i节点中记录了设备文件的主、辅ID(本章第4节将介绍i节点)。每个设备驱动程序都会将自己与特定主设备号的关联关系向内核注册,藉此建立设备专用文件和设备驱动程序之间的关系。内核是不会使用设备文件名来查找驱动程序的。
在Linux 2.4以及更早的版本中,系统的设备总数受限于这一事实:设备的主、辅ID只能用8位数来表示。加之主设备ID固定不变,且为统一分配(由Linux命名和编号机构分配,请见https://www.lanana.org ),使得上述问题更为严重。Linux 2.6采用了更多位数来存放主、辅ID(分别为12位和20位),从而缓解了这一问题。
用户和组
每个用户都拥有一个唯一的用户名和一个与之相关的数值型用户标识符(UID)。用户可以隶属于一个或多个组。而每个组也都拥有唯一的一个名称和一个组标识符(GID)。
用户和组ID的主要用途有二:其一,确定各种系统资源的所有权;其二,对赋予进程访问上述资源的权限加以控制。
vim /etc/passwd
root:0:0:root:/root:/bin/bash
bin:1:1:bin:/bin:/sbin/nologin
daemon:2:2:daemon:/sbin:/sbin/nologin
adm:3:4:adm:/var/adm:/sbin/nologin
…省略部分输出…
这个文件的内容非常规律,每行代表一个用户。大家可能会比较惊讶,Linux 系统中默认怎么会有这么多的用户啊!这些用户中的绝大多数是系统或服务正常运行所必需的用户,我们把这种用户称为系统用户或伪用户。系统用户是不能登录系统的,但是这些用户同样也不能被删除,因为一旦删除,依赖这些用户运行的服务或程序就不能正常执行,会导致系统问题。
现在我们就把 root 用户这一行拿出来,具体分析这个文件中的内容具体代表的含义。可以注意到,这个文件用":"作为分隔符,划分为 7 个字段,我们逐个来看具体的含义。
用户名称
第一个字段中保存的是用户名称。不过大家需要注意,用户名称只是为了方便管理员记忆,Linux 系统是通过用户 ID (UID) 来区分不同用户、分配用户权限的。而用户名称和 UID 的对应正是通过 /etc/passwd 这个文件来定义的。
密码标志
这里的"x"代表的是密码标志,而不是真正的密码,真正的密码是保存在 /etc/shadow 文件中的。在早期的 UNIX 中,这里保存的就是真正的加密密码串,但是这个文件的权限是 644,查询命令如下:
[root@localhost ~]# ll /etc/passwd
-rw-r–r-- 1 root root 1648 12月29 00:17 /etc/passwd
所有用户都可以读取 /etc/passwd 文件,这样非常容易导致密码的泄露。虽然密码是加密的,但是采用暴力破解的方式也是能够进行破解的。所以现在的 Linux 系统把真正的加密密码串放置在影子文件/etc/shadow中,而影子文件的权限是 000,查询命令如下:
[root@localhost ~]# ll /etc/shadow
---------- 1 root root 1028 12月29 00:18 /etc/shadow
这个文件是没有任何权限的,但因为我是 root 用户,所以读取权限不受限制。当然,用强制修改的方法也是可以手工修改这个文件的内容的。只有 root 用户可以浏览和操作这个文件,这样就最大限度地保证了密码的安全。
所以在 /etc/passwd 中只有一个"x"代表用户是拥有密码的,我们把这个字段称作密码标志,具体的密码要去 /etc/shadow 文件中查询。但是这个密码标志"x"也是不能被删除的,如果删除了密码标志"x",那么系统会认为这个用户没有密码,从而导致只输入用户名而不用输入密码就可以登陆(当然只能在使用无密码登录,远程是不可以的),除非特殊情况(如破解用户密码),这当然是不可行的。
UID
第三个字段就是用户 ID(UID),我们已经知道系统是通过 UID 来识别不同的用户和分配用户权限的。这些 UID 是有使用限制和要求的:
0:超级用户 UID。如果用户 UID 为 0,则代表这个账号是管理员账号。在 Linux 中如何把普通用户升级成管理员呢?只需把其他用户的 UID 修改为 0 就可以了,这一点和 Windows 是不同的。不过不建议建立多个管理员账号。
1~499:系统用户(伪用户)UID。这些 UID 是系统保留给系统用户的 UID,也就是说 UID 是 1~499 范围内的用户是不能登录系统的,而是用来运行系统或服务的。其中,1~99 是系统保留的账号,系统自动创建;100~499 是预留给用户创建账号的。
500~65535:普通用户 UID。建立的普通用户 UID 从 500 开始,最大到 65535。
GID
第四个字段就是用户的组 ID(GID),也就是这个用户的初始组的标志号。这里需要解释一下初始组和附加组的概念。
所谓初始组,指用户一登陆录就立刻拥有这个用户组的相关权限。每个用户的初始组只能有一个,一般就是将和这个用户的用户名相同的组名作为这个用户的初始组。举例来说,我们手工添加用户 lamp,在建立用户 lamp 的同时就会建立 lamp 组作为 lamp 用户的初始组。
所谓附加组,指用户可以加入多个其他的用户组,并拥有这些组的权限。每个用户只能有一个初始组,除初始组要把用户再加入其他的用户组外,这些用户组就是这个用户的附加组。附加组可以有多个,而且用户可以有这些附加组的权限。
举例来说,刚刚的 lamp 用户除属于初始组 lamp 外,我又把它加入了 users 组,那么 lamp 用户同时属于 lamp 组、users 组,其中 lamp 是初始组,users 是附加组。当然,初始组和附加组的身份是可以修改的,但是我们在工作中不修改初始组,只修改附加组,因为修改了初始组有时会让管理员逻辑混乱。
需要注意的是,在 /etc/passwd 文件的第四个字段中看到的 ID 是这个用户的初始组。
用户说明
第五个字段是这个用户的简单说明,没有什么特殊作用,可以不写。
家目录
第六个字段是这个用户的家目录,也就是用户登录后有操作权限的访问目录,我们把这个目录称为用户的家目录。
超级用户的家目录是 /root 目录,普通用户在 /home/ 目录下建立和用户名相同的目录作为家目录,如 lamp 用户的家目录就是 /home/lamp/ 目录。
登录之后的Shell
Shell 就是 Linux 的命令解释器。管理员输入的密码都是 ASCII 码,也就是类似 abcd 的英文。但是系统可以识别的编码是类似 0101 的机器语言。Shell 的作用就是把 ASCII 编码的命令翻译成系统可以识别的机器语言,同时把系统的执行结果翻译为用户可以识别的 ASCII 编码。Linux 的标准 Shell 就是 /bin/bash。
在 /etc/passw 文件中,大家可以把这个字段理解为用户登录之后所拥有的权限。如果写入的是 Linux 的标准 Shell,/bin/bash 就代表这个用户拥有权限范围内的所有权限。例如:
[root@localhost ~]# vi /etc/passwd
lamp:502:502::/home/lamp:/bin/bash
我手工添加了 lamp 用户,它的登录 Shell 是 /bin/bash,那么这个用户就可以使用普通用户的所有权限。如果我把 lamp 用户的 Shell 修改为 /sbin/nologin,例如:
[root@localhost ~]# vi /etc/passwd
lamp:502:502::/home/lamp:/sbin/nologin
那么这个用户就不能登录了,因为 /sbin/nologin 就是禁止登录的 Shell。这样说明白了吗?如果我在这里放入的系统命令,如 /usr/bin/passwd,例如:
[root@localhost ~]#vi /etc/passwd
lamp:502:502::/home/lamp:/usr/bin/passwd
那么这个用户可以登录,但登录之后就只能修改自己的密码了。这里不能随便写入和登陆没有关系的命令,如 ls,否则系统不会识别这些命令,也就意味着这个用户不能登录。
获取用户和组的信息
函数getpwnam()和getpwuid()的作用是从密码文件中获取记录。
#include<pwd.h>
struct passwd *getpwnam(const char *name);
struct passwd *getpwuid(uid_t uid);
struct passwd
{
char *pw_name; /* 用户登录名 */
char *pw_passwd; /* 密码(加密后) */
__uid_t pw_uid; /* 用户ID */
__gid_t pw_gid; /* 组ID */
char *pw_gecos; /* 详细用户名 */
char *pw_dir; /* 用户目录 */
char *pw_shell; /* Shell程序名 */
};
函数getgrnam()和getgrgid()的作用是从组文件中获取记录。
#include<grp.h>
struct passwd *getgrnam(const char *name);
struct passwd *getgrgid(gid_t gid);
struct group
{
char *gr_name; /* 组名 */
char *gr_passwd; /* 密码 */
__gid_t gr_gid; /* 组ID */
char **gr_mem; /* 组成员名单 */
}
扫描密码文件和组文件中的所有记录
函数setpwent()、getpwent()和endpwent()的作用是按顺序扫描密码文件中的记录。
#include<pwd.h>
struct passwd *getpwent(void);
可使用以下代码遍历整个密码文件,并打印出登录名和用户ID。
struct passwd *pwd;
while((pwd=getpwent())!=NULL)
{
printf("%-8s %5ld\n",pwd->pw_name,(long)pwd->pw_uid);
}
endpwent();
时间
- 程序可能会关注两种时间类型。
- 真实时间:度量这一时间的起点有二:一为某个标准点;二为进程生命周期内的某个固定时点(通常为程序启动)。前者为日历(calendar)时间,适用于需要对数据库记录或文件打上时间戳的程序;后者则称之为流逝(elapsed)时间或挂钟(wall clock)时间,主要针对需要周期性操作或定期从外部输入设备进行度量的程序。
进程时间:一个进程所使用的CPU时间总量,适用于对程序、算法性能的检查或优化。
大多数计算机体系结构都内置有硬件时钟,使内核得以计算真实时间和进程时间。
时间转换函数
日历时间(Calendar Time)
无论地理位置如何,UNIX系统内部对时间的表示方式均是以自Epoch以来的秒数来度量的,Epoch亦即通用协调时间(UTC,以前也称为格林威治标准时间,或GMT)的1970年1月1日早晨零点。这也是UNIX系统问世的大致日期。日历时间存储于类型为time_t的变量中,此类型是由SUSv3定义的整数类型。
在32位Linux系统,time_t是一个有符号整数,可以表示的日期范围从1901年12月13日20时45分52秒至2038年1月19号03:14:07。(SUSv3未定义time_t值为负数时的含义。)因此,当前许多32位UNIX系统都面临一个2038年的理论问题,如果执行的计算工作涉及未来日期,那么在2038年之前就会与之遭遇。事实上,到了 2038年,可能所有的UNIX系统都早已升级为64位或更多位数的系统,这一问题也许会随之而大为缓解。然而,32位嵌入式系统,由于其寿命较之台式机硬件更长,故而仍然会受此问题的困扰。此外,对于依然以32位time_t格式保存时间的历史数据和应用程序,这个问题将依然存在。
之所以存在两个本质上目的相同的系统调用(time()和gettimeofday()),自有其历史原因。早期的UNIX实现提供了time()。而4.3BSD又补充了更为精确的gettimeofday()系统调用。这时,再将 time()作为系统调用就显得多余,可以将其实现为一个调用gettimeofday()的库函数。
系统调用gettimeofday(),可于tv指向的缓冲区中返回日历时间。
#include<sys/time.h>
int gettimeofday(struct timeval *tv, struct timezone *tz);
struct timeval{
long tv_sec;/*秒*/
long tv_usec;/*微妙*/
};
gettimeofday()会把目前的时间用tv 结构体返回,当地时区的信息则放到tz所指的结构中.
gettimeofday()的参数tz是个历史产物。早期的UNIX实现用其来获取系统的时区信息,目前已遭废弃,应始终将其置为NULL。
time()系统调用返回自 Epoch 以来的秒数(和函数 gettimeofday()所返回的 tv 参数中tv_sec字段的数值相同)。
#include<time.h>
time_t time(time_t *timep);
//成功返回时间,失败返回-1
将time_t转换为可打印格式
为了将time_t转换为可打印格式,ctime()函数提供了一个简单方法。
#include<time.h>
char *ctime(const time_t *timep);
//失败返回空指针
把一个指向time_t的指针作为timep参数传入函数ctime(),将返回一个长达26字节的字符串,内含标准格式的日期和时间。
time_t和分解时间之间的转换
函数gmtime()和localtime()可将一time_t值转换为一个所谓的分解时间(broken-down time)。分解时间被置于一个经由静态分配的结构中,其地址则作为函数结果返回。
#include<time.h>
struct tm *gmtime(const time_t *timep);
struct tm *localtime(const time_t *timep);
struct tm{
int tm_sec; //取值[0,59],非正常情况下可到达61
int tm_min; //取值同上
int tm_hour; //取值[0,23]
int tm_mday; //取值[1,31]
int tm_mon; //取值[0,11]
int tm_year; //1900年起距今的年数
int tm_wday; //取值[0,6]
int tm_yday; //取值[0,366]
int tm_isdst; //夏令时标志
};
函数gmtime()能够把日历时间转换为一个对应于UTC的分解时间。(字母GM源于格林威治标准时间)。
相形之下,函数localtime()需要考虑时区和夏令时设置,返回对应于系统本地时间的一个分解时间。
函数 mktime() 将一个本地时区的分解时间翻译为 time_t值,并将其作为函数结果返回。
#include<time.h>
time_t *mktime(struct tm *timeptr);
分解时间和打印格式之间的转换
在参数tm中提供一个指向分解时间结构的指针,asctime()则会返回一指针,指向经由静态分配的字符串,内含时间,格式则与ctime ()相同。
#include<time.h>
char *asctime(const struct tm *timeptr)
当把一个分解时间转换成打印格式时,函数 strftime()可以提供更为精确的控制。令timeptr指向分解时间,strftime()会将以null结尾、由日期和时间组成的相应字符串置于outstr所指向的缓冲区中。
#include<time.h>
size_t strftime(char *outstr,size_t maxsize,const char *format,const struct tm *timeptr);
//失败返回0
outstr中返回的字符串按照format参数定义的格式做了格式化。Maxsize参数指定 outstr的最大长度。不同于 ctime()和asctime(),strftime()不会在字符串的结尾包括换行符(除非format中定义有换行符)。
将打印格式时间转换为分解时间
函数strptime()是strftime()的逆向函数,将包含日期和时间的字符串转换成一分解时间。
#include <time.h>
char *strptime(const char *str,const char *format, struct tm *timeptr);
时区
不同的国家(有时甚至是同一国家内的不同地区)使用不同的时区和夏时制。对于要输入和输出时间的程序来说,必须对系统所处的时区和夏时制加以考虑。
时区信息往往是既浩繁又多变的。出于这一原因,系统没有将其直接编码于程序或函数库中,而是以标准格式保存于文件中,并加以维护。
这些文件位于目录/usr/share/zoneinfo中。该目录下的每个文件都包含了一个特定国家或地区内时区制度的相关信息,且往往根据其所描述的时区来加以命名,诸如EST(美国东部标准时间)、CET(欧洲中部时间)、UTC、Turkey和Iran。此外,可以利用子目录对相关时区进行有层次的分组。例如,Pacific 目录就可能包含文件 Auckland、Port_Moresby和Galapagos。在程序中指定使用的时区,实际上是指定该目录下某一时区文件的相对路径名。
系统的本地时间由时区文件/etc/localtime定义,通常链接到/usr/share/zoneinfo下的一个文件。
为运行中的程序指定一个时区,需要将TZ环境变量设置为由一冒号(:)和时区名称组成的字符串,其中时区名称定义于/usr/share/zoneinfo中。设置时区会自动影响到函数ctime()、localtime()、mktime()和strftime()。
地区(Locale)
世界各地在使用数千种语言,其中在计算机系统上经常使用的占了相当比例。此外,在显示诸如数字、货币金额、日期和时间之类的信息时,不同国家的习俗也不同。例如,大多数欧洲国家使用逗号,而非小数点来分隔实数的整数和小数部分,大多数国家日期的书写格式也与美国所采用的MM/DD/ YY格式并不相同。SUSv3对 locale的定义为:用户环境中依赖于语言和文化习俗的一个子集。
和时区信息一样,地区信息同样是既浩繁且多变的。出于这一原因,与其要求各个程序和函数库来存储地区信息,还不如由系统按标准格式将地区信息存储于文件中,并加以维护。
地区信息维护于/usr/share/local(在一些发行版本中为/usr/lib/local)之下的目录层次结构中。该目录下的每个子目录都包含一特定地区的信息。
函数setlocale()既可设置也可查询程序的当前地区。
#include<local.h>
char* setlocale (int category, const char* locale);
category参数选择设置或查询地区的哪一部分,它仅能使用表 10-2中列出的地区类别的常量名称。因此,它可以设置地区的时间显示格式是德国,而地区的货币符号是美元。或者,更常见的是,我们可以利用LC_ALL来指定我们要设置的地区的所有部分的值。
更新系统时钟
我们现在来看两个更新系统时钟的接口:settimeofday()和adjtime()。这些接口都很少被应用程序使用,因为系统时间通常是由工具软件维护,如网络时间协议(Network Time Protocol)守护进程,并且它们需要调用者已被授权(CAP_SYS_TIME)。
系统调用settimeofday()是gettimeofday()的逆向操作(这是我们在10.1节中描述的)。它将tv指向timeval结构体里的秒数和微秒数,设置到系统的日历时间。
进程时间
进程时间是进程创建后使用的CPU时间数量。出于记录的目的,内核把CPU时间分成以下两部分。
- 用户 CPU 时间是在用户模式下执行所花费的时间数量。有时也称为虚拟时间(virtual time),这对于程序来说,是它已经得到CPU的时间。
- 系统CPU时间是在内核模式中执行所花费的时间数量。这是内核用于执行系统调用或代表程序执行的其他任务(例如,服务页错误)的时间。
当我们运行一个shell程序,我们可以使用的time(1)命令,同时获得这两个部分的时间值,以及运行程序所需的实际时间。
time ./a.out
real 0m0.491s
user 0m0.073s
sys 0m0.287s
clock()是C/C++中的计时函数,而与其相关的数据类型是clock_t。在MSDN中,查得对clock函数定义如下:
#include<time.h>
clock_t clock(void) ;
简单而言,就是该程序从启动到函数调用占用CPU的时间。这个函数返回从“开启这个程序进程”到“程序中调用clock()函数”时之间的CPU时钟计时单元(clock tick)数,在MSDN中称之为挂钟时间(wal-clock);若挂钟时间不可取,则返回-1。其中clock_t是用来保存时间的数据类型。
系统限制和选项
系统限制
SUSv3要求,针对其所规范的每个限制,所有实现都必须支持一个最小值。在大多数情况下,会将这些最小值定义为<limits.h>文件中的常量,其命名则冠以字符串_POSIX_,而且(通常)还包含字符串_MAX,因此,常量命名形如_POSIX_XXX_MAX。
运行时恒定值(可能不确定)
MQ_PRIO_MAX限制就是运行时恒定值的例子之一。针对POSIX消息队列中的消息,存在着优先级方面的限制。SUSv3 定义了值为 32 的常量_POSIX_MQ_PRIO_MAX,将其作为符合规范的实现为该限制所必须提供的最小值。这意味着,所有符合规范的实现,其对消息优先级的支持至少应为从0~31。一个UNIX实现可以为此限制设定更高值,并将该值在<limits.h>文件中以常量MQ_PRIO_MAX加以定义。例如,Linux就将MQ_PRIO_MAX的值定义为32768。也可以通过下列调用在运行时获取该值:
lim=sysconf(_SC_MQ_PRIO_MAX);
路径名变量值
所谓路径名变量值是指与路径名(文件、目录、终端等)相关的限制,每个限制可能是相对于某个系统实现的常量,也可能随文件系统的不同而不同。在限制可能因路径名而发生变化的情况下,应用程序可以使用pathconf()或fpathconf()来获取该值。
NAME_MAX限制是路径名变量值的例子之一。此限制定义了在一个特定文件系统中文件名的最大长度。SUSv3定义了值为 14 (老版本的 System V 文件系统限制)的常量_POSIX_NAME_MAX,作为系统实现必须支持的最小限制值。系统实现可以定义一个高于此值的NAME_MAX限制,并/或向应用开放如下形式的调用,以获取特定文件系统的相关信息:
lim=pathconf(director_path,PC_NAME_PATH);
参数directory_path是目标文件系统上的目录路径名。
运行时可增加值
运行时可增加值是指某一限制,相对于特定实现其值固定,且运行此实现的所有系统至少都应支持这一最小值。然而,特定系统在运行时可能会增加该值,应用程序可以使用sysconf()来获得系统所支持的实际值。
运行时可增加值的例子之一是NGROUPS_MAX,该限制定义了一进程可同时从属的辅助组ID的最大数量。SUSv3定义了相应的最小值_POSIX_NGROUPS_MAX,其值为8。应用可在运行时通过调用sysconf(_SC_NGROUPS_MAX)来获取此限制值。
从shell中获取限制和选项:getconf
在shell中,可以使用getconf命令获取特定UNIX系统中已然实现的限制和选项。该命令的格式一般如下:
getconf variable-name[pathname]
variable-name标识用户意欲获取的限制,应是符合SUSV3标准的限制名称,例如:ARG_MAX或NAME_MAX。但凡限制与路径名相关,则还需要指定一个路径名,作为命令的第二个参数,如下第二个实例所示。
getconf ARG_MAX
getconf NAME_MAX /boot
262144
255
在运行时获取系统限制(和选项)
sysconf()函数允许应用程序在运行时获得系统限制值。
#include <unistd.h>
long sysconf (int name);
参数name应为定义于<unistd.h>
文件中的_SC_系列常量之一.
运行时获取与文件相关的限制(和选项)
pathconf()和fpathconf()函数允许应用程序在运行时获取文件相关的限制值。
#include <unistd.h>
long pathconf(const char* path,int name);
long fpathconf(int fd,int name);
pathconf()和fpathconf()之间唯一的区别在于对文件或目录的指定方式。pathconf()采用路径名方式来指定,而fpathconf()则使用(之前已经打开的)文件描述符。
参数name则是定义于<unistd.h>
文件中的_PC_系列常量之一.
系统选项
除了对各种系统资源的限制加以规范外,SUSv3还规定了UNIX实现可支持的各种选项。这包括对诸如实时信号、POSIX共享内存、任务控制以及POSIX线程之类功能的支持。除少数特例外,并未要求实现支持这些选项。相反,对于实现在编译及运行时是否支持某一特定特性,SUSv3允许实现自行给出建议。
通过在<unistd.h>
文件中定义相应常量,实现能够在编译时通告其对特定SUSv3选项的支持。此类常量的命名均会冠以前缀(比如_POSIX_ 或者_XOPEN_),以标识其源于何种标准。
对于系统实现必须支持的限制和可能支持的系统选项,SUSv3都做了规范。
通常,不建议将对系统限制和选项的假设值硬性写入应用程序代码,因为这些值既可能随系统的不同而发生变化,也可能在同一个系统实现中因不同的运行期间或文件系统而不同。因此,SUSv3规定了一干方法,借助于此,系统实现可发布其所支持的限制和选项。对于大多数限制,SUSv3规定了所有实现所必须支持的最小值。此外,每个实现还能在编译时(通过定义于<limits.h>或<unistd.h>文件中的常量)和/或运行时(通过调用 sysconf()、pathconf()或 fpathconf()函数) 发布其特有的限制和选项。此类技术同样可应用于找出实现所支持的SUSv3选项。在一些情况下,无论使用上述何种方法,都不能获取某个特定限制的值。对于这些不确定的限制,必须采用特殊技术来确定应用程序所应遵循的限制。
系统和进程信息
/proc文件系统
在较老的UNIX实现中,通常并无简单方法来获取(或修改)内核属性并回答如下问题:
系统中有多少进程正在运行,其属主是谁?
一个进程已经打开了什么文件?
目前锁定了什么文件,哪些进程持有这些锁?
系统正在使用什么套接字(socket)?
一些老版UNIX实现解决这一问题的方法是允许特权级程序深入内核内存中的数据结构。然而,这会带来各种问题。特别是,这要求对内核数据结构具有专业知识,并且这些结构可能因内核版本的演进而发生改变,故而需要加以重写。
为了提供更为简便的方法来访问内核信息,许多现代UNIX实现提供了一个/proc虚拟文件系统。该文件系统驻留于/proc目录中,包含了各种用于展示内核信息的文件,并且允许进程通过常规文件I/O系统调用来方便地读取,有时还可以修改这些信息。之所以将/proc文件系统称为虚拟,是因为其包含的文件和子目录并未存储于磁盘上,而是由内核在进程访问此类信息时动态创建而成。
获取与进程有关的信息:/proc/PID
对于系统中每个进程,内核都提供了相应的目录,命名为/proc/PID,其中PID是进程的ID。在此目录中的各种文件和子目录包含了进程的相关信息。例如,通过查看/proc/1目录下的文件,可以获取init进程的信息,该进程的ID总是为1。
每个/proc/PID目录中都存在一个命名为status的文件,提供了有关该进程的一系列信息。
cat /proc/1/status
Name: systemd
Umask: 0000
State: S (sleeping)
Tgid: 1
Ngid: 0
Pid: 1
PPid: 0
TracerPid: 0
Uid: 0 0 0 0
Gid: 0 0 0 0
...
每个/proc/PID目录下的文件节选
cmdline,以\0分隔的命令行参数
cwd,指向当前工作目录的符号链接
Environ,NAME=value 键值对环境列表,以\0分隔
exe,指向正在执行文件的符号链接
fd,文件目录,包含了指向由进程打开文件的符号链接
maps,内存映射
mem,进程虚拟内存(在I/O操作之前必须调用lseek()移至有效偏移量)
mounts,进程的安装点
root,指向根目录的符号链接
status,各种信息(比如,进程ID、凭证、内存使用量、信号)
task,为进程中的每个线程均包含一个子目录(始自Linux 2.6)
/proc 目录下的系统信息
/proc目录下的各种文件和子目录提供了对系统级信息的访问。
访问/proc文件
通常使用shell脚本来访问/proc目录下的文件(使用诸如Python或者Perl之类的脚本语言,很容易解析大多数/proc目录下包含有多个值的文件)。例如,使用如下shell命令,就可以修改和查看/proc目录下的文件内容:
echo 100000 /proc/sys/kernel/pid_max
cat /proc/sys/kernel/pid_max
100000
上述命令需要su权限?
也可以从程序中使用常规I/O系统调用来访问/proc目录下的文件。但在访问这些文件时,有如下一些限制。
- /proc目录下的一些文件是只读的,即这些文件仅用于显示内核信息,但无法对其进行修改。/proc/PID目录下的大多数文件就属于此类型。
- /proc目录下的一些文件仅能由文件拥有者(或特权级进程)读取。例如,/proc/PID目录下的所有文件都属于拥有相应进程的用户,而且即使是对文件的属主,其中的部分文件(如:proc/PID/environ文件)也仅仅授予了读权限。
- 除了/proc/PID子目录中的文件,/proc目录的其他文件大多属于root用户,并且也仅有root用户能够修改那些可修改的文件。
系统标识:uname()
uname()系统调用返回了一系列关于主机系统的标识信息,存储于utsbuf所指向的结构中。
#include<sys/utsname.h>
int uname(struct utsname *utsbuf);
struct utsname{
char sysname[_UTSNAME_SYSNAME_LENGTH];//当前操作系统名
char nodename[_UTSNAME_NODENAME_LENGTH];//网络上的名称
char release[_UTSNAME_RELEASE_LENGTH];//当前发布级别
char version[_UTSNAME_VERSION_LENGTH];//当前发布版本
char machine[_UTSNAME_MACHINE_LENGTH];//当前硬件体系类型
# ifdef __USE_GNU
char domainname[_UTSNAME_DOMAIN_LENGTH]; //当前域名
# else
char __domainname[_UTSNAME_DOMAIN_LENGTH];
# endif
};
SUSv3规范了uname(),但对utsname结构中各种字段的长度未加定义,仅要求字符串以空字节终止。在Linux中,这些字段长度均为65个字节,其中包括了空字节终止符所占用的空间。而在一些UNIX实现中,这些字段更短,但在其他操作系统(如Solaris)中,这些字段的长度长达257个字节。
utsname结构中的sysname、release、version和machine字段由内核自动设置。
在Linux中,/proc/sys/kernel目录下的3个文件提供了与utsname结构的sysname、release和 version 字段返回值相同的信息,这些只读文件分别为 ostype、osrelease和version。另外一个文件/proc/version,也包含了这些信息,并且还包含了有关内核编译的步骤信息(即执行编译的用户名、用于编译的主机名,以及使用的gcc版本)。
使用uname()系统调用,能够获取UNIX的实现信息以及应用程序所运行的机器类型。
信号 Signal
信号是事件发生时对进程的通知机制。有时也称之为软件中断。信号与硬件中断的相似之处在于打断了程序执行的正常流程,大多数情况下,无法预测信号到达的精确时间。
一个(具有合适权限的)进程能够向另一进程发送信号。信号的这一用法可作为一种同步技术,甚至是进程间通信(IPC)的原始形式。进程也可以向自身发送信号。然而,发往进程的诸多信号,通常都是源于内核。引发内核为进程产生信号的各类事件如下。
- 硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程。硬件异常的例子包括执行一条异常的机器语言指令,诸如,被0除,或者引用了无法访问的内存区域。
- 用户键入了能够产生信号的终端特殊字符。其中包括中断字符(通常是Control-C)、暂停字符(通常是Control-Z)。
- 发生了软件事件。例如,针对文件描述符的输出变为有效,调整了终端窗口大小,定时器到期,进程执行的CPU时间超限,或者该进程的某个子进程退出。
引发信号的原因:
1.键盘事件 ctrl +c ctrl +
2.非法内存 如果内存管理出错,系统就会发送一个信号进行处理
3.硬件故障 同样的,硬件出现故障系统也会产生一个信号
4.环境切换 比如说从用户态切换到其他态,状态的改变也会发送一个信号,这个信号会告知给系统
针对每个信号,都定义了一个唯一的(小)整数,从1开始顺序展开。<signal.h>
以SIGxxxx形式的符号名对这些整数做了定义。由于每个信号的实际编号随系统不同而不同,所以在程序中总是使用这些符号名。例如,当用户键入中断字符时,将传递给进程SIGINT信号(信号编号为2)。
信号分为两大类。第一组用于内核向进程通知事件,构成所谓传统或者标准信号。Linux中标准信号的编号范围为1~31。另一组信号由实时信号构成..
信号因某些事件而产生。信号产生后,会于稍后被传递给某一进程,而进程也会采取某些措施来响应信号。在产生和到达期间,信号处于等待(pending)状态。
信号到达后,进程视具体信号执行如下默认操作之一。
- 忽略信号:也就是说,内核将信号丢弃,信号对进程没有产生任何影响(进程永远都不知道曾经出现过该信号)。
- 终止(杀死)进程:这有时是指进程异常终止,而不是进程因调用exit()而发生的正常终止。
- 产生核心转储文件,同时进程终止:核心转储文件包含对进程虚拟内存的镜像,可将其加载到调试器中以检查进程终止时的状态。
- 停止进程:暂停进程的执行。
- 于之前暂停后再度恢复进程的执行。
除了根据特定信号而采取默认行为之外,程序也能改变信号到达时的响应行为。也将此称之为对信号的处置(disposition)设置。程序可以将对信号的处置设置为如下之一。
- 采取默认行为。这适用于撤销之前对信号处置的修改、恢复其默认处置的场景。
- 忽略信号。这适用于默认行为为终止进程的信号。
- 执行信号处理器程序。
查看信号:
kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
常用信号释义
名称 | 信号值 | 描述 | SUSv3 | 默认值 |
---|---|---|---|---|
SIGABRT | 6 | 中止进程 | ● | core |
SIGALRM | 14 | 实时定时器过期 | ● | term |
SIGBUS | 7 (SAMP=10) | 内存访问错误 | ● | core |
SIGCHLD | 17(SA=20, MP=18) | 终止或者停止子进程 | ● | ignore |
SIGCONT | 18 (SA=19, M=25, P=26) | 若停止则继续 | ● | cont |
SIGEMT | undef (SAMP=7) | 硬件错误 | - | term |
SIGFPE | 8 | 算术异常 | ● | core |
SIGHUP | 1 | 挂起 | ● | term |
SIGILL | 4 | 非法指令 | ● | core |
SIGINT | 2 | 终端中断 | ● | term |
SIGIO / | 29(SA=23, MP=22) | I/O 时可能产生 | ● | term |
SIGKILL | 9 | 必杀(确保杀死) | ● | term |
SIGPIPE | 13 | 管道断开 | ● | term |
SIGPROF | 27 (M=29, P=21) | 性能分析定时器过期 | ● | term |
SIGPWR | 30(SA=29, MP=19) | 电量行将耗尽 | - | term |
SIGQUIT | 3 | 终端退出 | ● | core |
SIGSEGV | 11 | 无效的内存引用 | ● | core |
SIGSTKFLT | 16 (SAM=undef, P=36) | 协处理器栈错误 | - | term |
SIGSTOP | 19(SA=17, M=23, P=24) | 确保停止 | ● | stop |
SIGSYS | 31 (SAMP=12) | 无效的系统调用 | ● | core |
SIGTERM | 15 | 终止进程 | ● | - |
SIGTRAP | 5 | 跟踪/断点陷阱 | ● | core |
SIGTSTP | 20 (SA=18, M=24, P=25) | 终端停止 | ● | stop |
SIGTTIN | 21 (M=26, P=27) | BG1从终端读取 | ● | stop |
SIGTTOU | 22 (M=27, P=28) | BG 向终端写 | ● | stop |
SIGURG | 23 (SA=16, M=21, P=29) | 套接字上的紧急数据 | ● | ignore |
SIGUSR1 | 10 (SA=30, MP=16) | 用户自定义信号1 | ● | term |
SIGUSR2 | 12 (SA=31, MP=17) | 用户自定义信号2 | ● | term |
SIGVTALRM | 26 (M=28, P=20) | 虚拟定时器过期 | ● | term |
SIGWINCH | 28 (M=20, P=23) | 终端窗口尺寸发生变化 | - | ignore |
SIGXCPU | 24 (M=30, P=33) | 突破对CPU 时间的限制 | ● | core |
SIGXFSZ | 25 (M=31, P=34) | 突破对文件大小的限制 | ● | core |
信号处理函数signal()
UNIX 系统提供了两种方法来改变信号处置:signal()和sigaction()。
signal()系统调用,是设置信号处置的原始 API,所提供的接口比sigaction()简单。
但sigaction()不属于 POSIX 标准,在各类 UNIX 平台上的实现不尽相同,因此其用途受到了一定的限制。而 POSIX 标准定义的信号处理接口是 sigaction 函数。故此,sigaction()是建立信号处理器的首选API(强力推荐)。
signal
#include <signal.h>
void (*signal(int sig,void (*handler)(int))) (int);
这里需要对signal()函数的原型做一些解释。第一个参数sig,标识希望修改处置的信号编号,第二个参数handler,则标识信号抵达时所调用函数的地址。该函数无返回值(void),并接收一个整型参数。因此,信号处理器函数一般具有以下形式:
void handler(int sig)
{
//信号处理函数
}
在为signal()指定handler参数时,可以以如下值来代替函数地址:
SIG_DFL
将信号处置重置为默认值(表20-1)。这适用于将之前signal()调用所改变的信号处置还原。
SIG_IGN
忽略该信号。如果信号专为此进程而生,那么内核会默默将其丢弃。进程甚至从未知道曾经产生了该信号。
例1:
#include<signal.h>
#include<stdio.h>
#include <unistd.h>
static void sigHandler(int sig)
{
printf("Ouch!\n");
}
int main()
{
int j;
if(signal(SIGINT,sigHandler)==SIG_ERR)
printf("signal");
for(j=0;;j++)
{
printf("%d\n",j);
sleep(3);
}
}
0
1
2
^Ouch!
3
4
...
主程序会持续循环。每次迭代,程序都将递增计数器值并将其打印出来,然后休眠几秒钟。
发送信号:kill()
与shell的kill命令相类似,一个进程能够使用kill()系统调用向另一进程发送信号。(之所以选择kill作为术语,是因为早期UNIX实现中大多数信号的默认行为是终止进程。)
#include<signal.h>
int kill(pid_t pid ,int sig);
//成功返回0,失败返回-1
pid参数标识一个或多个目标进程,而sig则指定了要发送的信号。如何解释pid,要视以下4种情况而定。
- 如果pid大于0,那么会发送信号给由pid指定的进程。
- 如果pid等于0,那么会发送信号给与调用进程同组的每个进程,包括调用进程自身。(SUSv3声明,除去“一组未予明确的系统进程”之外,应将信号发送给同一进程组中的所有进程,且这一排除条件同样适用于余下的两种情况。)
- 如果pid小于−1,那么会向组ID等于该pid绝对值的进程组内所有下属进程发送信号。向一个进程组的所有进程发送信号在 shell 作业控制中有特殊用途。
- 如果pid等于−1,那么信号的发送范围是:调用进程有权将信号发往的每个目标进程,除去init(进程ID为1)和调用进程自身。如果特权级进程发起这一调用,那么会发送信号给系统中的所有进程,上述两个进程除外。显而易见,有时也将这种信号发送方式称之为广播信号。(SUSv3并未要求将调用进程排除在信号的接收范围之外,Linux此处所遵循的是BSD系统的语义。)
如果并无进程与指定的pid相匹配,那么kill()调用失败,同时将errno置为ESRCH(“查无此进程”)。
进程要发送信号给另一进程,还需要适当的权限,其权限规则如下。
- 特权级(CAP_KILL)进程可以向任何进程发送信号。
- 以root用户和组运行的init进程(进程号为1),是一种特例,仅能接收已安装了处理器函数的信号。这可以防止系统管理员意外杀死init进程——这一系统运作的基石。
- 如果发送者的实际或有效用户ID匹配于接受者的实际用户ID或者保存设置用户ID(saved set-user-id),那么非特权进程也可以向另一进程发送信号。利用这一规则,用户可以向由他们启动的set-user-ID程序发送信号,而无需考虑目标进程有效用户ID的当前设置。将目标进程有效用户ID排除在检查范围之外,这一举措的辅助作用在于防止用户某甲向用户某乙的进程发送信号,而该进程正在执行的set-user-ID程序又属于用户某甲。(SUSv3要求强制执行图20-2所示的规则,但如kill(2)手册页所述,Linux内核在2.0版本之前所遵循的规则略有不同。)
- SIGCONT信号需要特殊处理。无论对用户ID的检查结果如何,非特权进程可以向同一会话中的任何其他进程发送这一信号。利用这一规则,运行作业控制的shell可以重启已停止的作业(进程组),即使作业进程已经修改了它们的用户ID。
如果进程无权发送信号给所请求的pid,那么kill()调用将失败,且将errno置为EPERM。若pid所指为一系列进程(即pid是负值)时,只要可以向其中之一发送信号,则kill()调用成功。
检查进程的存在
kill()系统调用还有另一重功用。若将参数sig指定为0(即所谓空信号),则无信号发送。相反,kill()仅会去执行错误检查,查看是否可以向目标进程发送信号。从另一角度来看,这意味着,可以使用空信号来检测具有特定进程ID的进程是否存在。若发送空信号失败,且errno为ESRCH,则表明目标进程不存在。如果调用失败,且errno为EPERM(表示进程存在,但无权向目标进程发送信号)或者调用成功(有权向进程发送信号),那么就表示进程存在。
发送信号的其他方式:raise()和killpg()
有时,进程需要向自身发送信号。raise()函数就执行了这一任务。
#include<signal.h>
int raise(int sig);
在单线程程序中,调用raise()相当于对kill()的如下调用:
kill(getpid(),sig);
支持线程的系统会将raise(sig)实现为:
pthread_kill(pthread_self(),sig);
pthread_kill()意味着将信号传递给调用raise()的特定线程。相比之下,kill(getpid(), sig)调用会发送一个信号给调用进程,并可将该信号传递给该进程的任一线程。
killpg()函数向某一进程组的所有成员发送一个信号。
#include<signal.h>
int killpg(pid_t pgrp,int sig);
killpg()调用相当于对kill()的如下调用:
kill(-pgrp,int sig);
显示信号描述
#include<signal.h>
char *strsignal(int sig);
strsignal()函数对sig参数进行边界检查,然后返回一枚指针,指向针对该信号的可打印描述字符串,或者是当信号编号无效时指向错误字符串。(在其他一些UNIX实现中,strsignal()函数会在sig无效时返回空值。)
#include<signal.h>
void psignal(int sig,const char *msg);
信号集
多个信号可使用一个称之为信号集的数据结构来表示,其系统数据类型为sigset_t.。SUSv3规定了一系列函数来操纵信号集.
sigemptyset()函数初始化一个未包含任何成员的信号集。sigfillset()函数则初始化一个信号集,使其包含所有信号(包括所有实时信号)。
#include<signal.h>
int sigemptyset(sigset_t *set);
//成功返回0,失败返回-1
int sigfillset(sigset_t *set);
//成功返回0,失败返回-1
必须使用sigemptyset()或者sigfillset()来初始化信号集。这是因为C语言不会对自动变量进行初始化,并且,借助于将静态变量初始化为0的机制来表示空信号集的作法在可移植性上存在问题,因为有可能使用位掩码之外的结构来实现信号集。(出于同一原因,为将信号集标记为空而使用memset(3)函数将其内容清零的做法也不正确。)
信号集初始化后,可以分别使用 sigaddset()和 sigdelset()函数向一个集合中添加或者移除单个信号。
#include<signal.h>
int sigaddset(sigset_t *set,int sig);
//成功返回0,失败返回-1
int sigfillset(sigset_t *set,int sig);
//成功返回0,失败返回-1
sigismember()函数用来测试信号sig是否是信号集set的成员。
#include<signal.h>
int sigaddset(sigset_t *set,int sig);
//是成员返回1,不是成员则返回0
信号掩码(阻塞信号传递)
内核会为每个进程维护一个信号掩码,即一组信号,并将阻塞其针对该进程的传递。如果将遭阻塞的信号发送给某进程,那么对该信号的传递将延后,直至从进程信号掩码中移除该信号,从而解除阻塞为止。
向信号掩码中添加一个信号,有如下几种方式。
- 当调用信号处理器程序时,可将引发调用的信号自动添加到信号掩码中。是否发生这一情况,要视sigaction()函数在安装信号处理器程序时所使用的标志而定。
- 使用sigaction()函数建立信号处理器程序时,可以指定一组额外信号,当调用该处理器程序时会将其阻塞。
- 使用sigprocmask()系统调用,随时可以显式向信号掩码中添加或移除信号。
#include<signal.h>
int sigprocmask(int how,cosnt sigset_t *sel, sigset_t *oldsel);
//成功返回0,失败返回-1
使用sigprocmask()函数既可修改进程的信号掩码,又可获取现有掩码,或者两重功效兼具。how参数指定了sigprocmask()函数想给信号掩码带来的变化。
SIG_BLOCK
将set指向信号集内的指定信号添加到信号掩码中。换言之,将信号掩码设置为其当前值和set的并集。
SIG_UNBLOCK
将set指向信号集中的信号从信号掩码中移除。即使要解除阻塞的信号当前并未处于阻塞状态,也不会返回错误。
SIG_SETMASK
将set指向的信号集赋给信号掩码。
上述各种情况下,若oldset参数不为空,则其指向一个sigset_t结构缓冲区,用于返回之前的信号掩码。
如果想获取信号掩码而又对其不作改动,那么可将set参数指定为空,这时将忽略how参数。
sigset_t blockSet,precMask;
//初始化信号集
sigemptyset(&blockSet);
sigaddset(&blockSet,SIFINT);
if(sigprocmask(SIG_BLOCK,&blockSet,&precMask)==-1)
printf("sigprocmask1");
if(sigprocmask(SIG_SETMASK,&prevMask,NULL)==-1)
printf("sigprocmask2");
处于等待状态的信号
如果某进程接受了一个该进程正在阻塞的信号,那么会将该信号填加到进程的等待信号集中。当(且如果)之后解除了对该信号的锁定时,会随之将信号传递给此进程。为了确定进程中处于等待状态的是哪些信号,可以使用sigpending()。
#include<signal.h>
int sigpending(sigset_t *set);
sigpending()系统调用为调用进程返回处于等待状态的信号集,并将其置于 set 指向的sigset_t结构中。
不对信号进行排队处理
等待信号集只是一个掩码,仅表明一个信号是否发生,而未表明其发生的次数。换言之,如果同一信号在阻塞状态下产生多次,那么会将该信号记录在等待信号集中,并在稍后仅传递一次。(标准信号和实时信号之间的差异之一在于,对实时信号进行了排队处理。)
TODO.
改变信号处置:sigaction ()
除去 signal()之外,sigaction()系统调用是设置信号处置的另一选择。虽然sigaction()的用法比之signal()更为复杂,但作为回报,也更具灵活性。尤其是,sigaction()允许在获取信号处置的同时无需将其改变,并且,还可设置各种属性对调用信号处理器程序时的行为施以更加精准的控制。此外,在建立信号处理器程序时,sigaction()较之signal()函数可移植性更佳。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
//成功返回0,失败返回-1
sig参数标识想要获取或改变的信号编号。该参数可以是除去SIGKILL和SIGSTOP之外的任何信号。
oldact:原来对信号的处理方式。
act参数是一枚指针,指向描述信号新处置的数据结构。如果仅对信号的现有处置感兴趣,那么可将该参数指定为NULL。oldact参数是指向同一结构类型的指针,用来返回之前信号处置的相关信息。如果无意获取此类信息,那么可将该参数指定为NULL。act和oldact所指向的结构类型如下所示:
struct sigaction
{
void (*sa_handler)(int); //信号处理函数
sigset_t sa_mask; //需要被屏蔽的信号
int sa_flags; //成员用于指定信号处理的行为,它可以是一下值的“按位或”组合。
void (*sa_restorer)(void); //已经废弃的数据域,不要使用。
};
sa_flags可选行为:
◆ SA_RESTART:使被信号打断的系统调用自动重新发起。
◆ SA_NOCLDSTOP:使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号。
◆ SA_NOCLDWAIT:使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程。
◆ SA_NODEFER:使对信号的屏蔽无效,即在信号处理函数执行期间仍能发出这个信号。
◆ SA_RESETHAND:信号处理之后重新设置为默认的处理方式。
◆ SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数。
例1:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
static void sig_usr(int signum)
{
if(signum == SIGUSR1)
{
printf("SIGUSR1 received\n");
}
else if(signum == SIGUSR2)
{
printf("SIGUSR2 received\n");
}
else
{
printf("signal %d received\n", signum);
}
}
int main(void)
{
char buf[512];
int n;
struct sigaction sa_usr;
sa_usr.sa_flags = 0;
sa_usr.sa_handler = sig_usr; //信号处理函数
sigaction(SIGUSR1, &sa_usr, NULL);
sigaction(SIGUSR2, &sa_usr, NULL);
printf("My PID is %d\n", getpid());
while(1)
{
if((n = read(STDIN_FILENO, buf, 511)) == -1)
{
if(errno == EINTR)
{
printf("read is interrupted by signal\n");
}
}
else
{
buf[n] = '\0';
printf("%d bytes read: %s\n", n, buf);
}
}
return 0;
}
gcc main.c
./a.out
在这个例程中使用 sigaction 函数为 SIGUSR1 和 SIGUSR2 信号注册了处理函数,然后从标准输入读入字符。程序运行后首先输出自己的 PID,如:My PID is 5904
此时输入内容会有得到输出。
这时启用另一个终端向进程发送 SIGUSR1 或 SIGUSR2 信号,用类似如下的命令:kill -USR1 5904
则程序将继续输出如下内容:
SIGUSR1 received
read is interrupted by signal
这说明用 sigaction 注册信号处理函数时,不会自动重新发起被信号打断的系统调用。如果需要自动重新发起,则要设置 SA_RESTART 标志,比如在上述例程中可以进行类似一下的设置:
sa_usr.sa_flags = SA_RESTART;
等待信号:pause()
调用pause()将暂停进程的执行,直至信号处理器函数中断该调用为止(或者直至一个未处理信号终止进程为止)。
#include<unistd.h>
int pause(void);
调用pause()将暂停进程的执行,直至信号处理器函数中断该调用为止(或者直至一个未处理信号终止进程为止)。
例1:
#include<signal.h>
#include<stdio.h>
#include <unistd.h>
int main()
{
printf("124\n");
alarm( 5 );
pause();
printf("end\n");
return 0;
}
124
Alarm clock
设计信号处理器函数
一般而言,将信号处理器函数设计得越简单越好。其中的一个重要原因就在于,这将降低引发竞争条件的风险。下面是针对信号处理器函数的两种常见设计。
- 信号处理器函数设置全局性标志变量并退出。主程序对此标志进行周期性检查,一旦置位随即采取相应动作。(主程序若因监控一个或多个文件描述符的I/O状态而无法进行这种周期性检查时,则可令信号处理器函数向一专用管道写入一个字节的数据,同时将该管道的读取端置于主程序所监控的文件描述符范围之内。63.5.2节展示了这一技术的运用。)
- 信号处理器函数执行某种类型的清理动作,接着终止进程或者使用非本地跳转(21.2.1节)将栈解开并将控制返回到主程序中的预定位置。
再论信号的非队列化处理
在执行某信号的处理器函数时会阻塞同类信号的传递(除非在调用sigaction()时指定了SA_NODEFER标志)。如果在执行处理器函数时(再次)产生同类信号,那么会将该信号标记为等待状态并在处理器函数返回之后再行传递。在处理器函数执行期间,如果多次产生同类信号,那么仍然会将其标记为等待状态,但稍后只会传递一次。
信号的这种“失踪”方式无疑将影响对信号处理器函数的设计。首先,无法对信号的产生次数进行可靠计数。其次,在为信号处理器函数编码时可能需要考虑处理同类信号多次产生的情况。
可重入函数和异步信号安全函数
在信号处理器函数中,并非所有系统调用以及库函数均可予以安全调用。要了解来龙去脉,就需要解释一下以下两种概念:可重入(reentrant)函数和异步信号安全(async-signal-safe)函数。
可重入和非可重入函数
要解释可重入函数为何物,首先需要区分单线程程序和多线程程序。典型UNIX程序都具有一条执行线程,贯穿程序始终,CPU围绕单条执行逻辑来处理指令。而对于多线程程序而言,同一进程却存在多条独立、并发的执行逻辑流。
如果同一个进程的多条线程可以同时安全地调用某一函数,那么该函数就是可重入的。此处,“安全”意味着,无论其他线程调用该函数的执行状态如何,函数均可产生预期结果。
标准的异步信号安全函数
异步信号安全的函数是指当从信号处理器函数调用时,可以保证其实现是安全的。如果某一函数是可重入的,又或者信号处理器函数无法将其中断时,就称该函数是异步信号安全的。
POSIX.1-1990、SUSv2和SUSv3规定为异步信号安全的函数
无 | 无 | 无 |
---|---|---|
_Exit() (v3) | getpid() | sigdelset() |
_exit() | getppid() | sigemptyset() |
abort() (v3) | getsockname() (v3) | sigfillset() |
accept() (v3) | getsockopt() (v3) | sigismember() |
access() | getuid() | signal() (v2) |
aio_error() (v2) | kill() | sigpause() (v2) |
aio_return() (v2) | link() | sigpending() |
aio_suspend() (v2) | listen() (v3) | sigprocmask() |
alarm() | lseek() | sigqueue() (v2) |
bind() (v3) | lstat() (v3) | sigset() (v2) |
cfgetispeed() | mkdir() | sigsuspend() |
cfgetospeed() | mkfifo() | sleep() |
cfsetispeed() | open() | socket() (v3) |
cfsetospeed() | pathconf() | sockatmark() (v3) |
chdir() | pause() | socketpair() (v3) |
chmod() | pipe() | stat() |
chown() | poll() (v3) | symlink() (v3) |
clock_gettime() (v2) | posix_trace_event() (v3) | sysconf() |
close() | pselect() (v3) | tcdrain() |
connect() (v3) | raise() (v2) | tcflow() |
creat() | read() | tcflush() |
dup() | readlink() (v3) | tcgetattr() |
dup2() | recv() (v3) | tcgetpgrp() |
execle() | recvfrom() (v3) | tcsendbreak() |
execve() | recvmsg() (v3) | tcsetattr() |
fchmod() (v3) | rename() | tcsetpgrp() |
fchown() (v3) | rmdir() | time() |
fcntl() | select() (v3) | timer_getoverrun() (v2) |
fdatasync() (v2) | sem_post() (v2) | timer_gettime() (v2) |
fork() | send() (v3) | timer_settime() (v2) |
fpathconf() (v2) | sendmsg() (v3) | times() |
fstat() | sendto() (v3) | umask() |
fsync() (v2) | setgid() | uname() |
ftruncate() (v3) | setpgid() | unlink() |
getegid() | setsid() | utime() |
geteuid() | setsockopt() (v3) | wait() |
getgid() | setuid() | waitpid() |
getgroups() | shutdown() (v3) | write() |
getpeername() (v3) | sigaction() | |
getpgrp() | sigaddset() |
全局变量和sig_atomic_t数据类型
尽管存在可重入问题,有时仍需要在主程序和信号处理器函数之间共享全局变量。信号处理器函数可能会随时修改全局变量——只要主程序能够正确处理这种可能性,共享全局变量就是安全的。例如,一种常见的设计是,信号处理器函数只做一件事情,设置全局标志。主程序则会周期性地检查这一标志,并采取相应动作来响应信号传递(同时清除标志)。当信号处理器函数以此方式来访问全局变量时,应该总是在声明变量时使用volatile关键字,从而防止编译器将其优化到寄存器中。
对全局变量的读写可能不止一条机器指令,而信号处理器函数就可能会在这些指令序列之间将主程序中断(也将此类变量访问称为非原子操作)。因此,C语言标准以及SUSv3定义了一种整型数据类型sig_atomic_t,意在保证读写操作的原子性。因此,所有在主程序与信号处理器函数之间共享的全局变量都应声明如下:
volatile sig_atomic_t flag;
注意,C语言的递增(++)和递减(--)操作符并不在sig_atomic_t所提供的保障范围之内。这些操作在某些硬件架构上可能不是原子操作.
终止信号处理器函数的其他方法
目前为止所看到的信号处理器函数都是以返回主程序而终结。不过,只是简单地从信号处理器函数中返回并不能满足需要,有时候甚至没什么用处。
以下是从信号处理器函数中终止的其他一些方法。
- 使用_exit()终止进程。处理器函数事先可以做一些清理工作。注意,不要使用exit()来终止信号处理器函数,因为它不在表21-1所列的安全函数中。之所以不安全,是因为该函数会在调用_exit()之前刷新stdio的缓冲区。
- 使用kill()发送信号来杀掉进程(即,信号的默认动作是终止进程)。
- 从信号处理器函数中执行非本地跳转。
- 使用abort()函数终止进程,并产生核心转储。
系统调用的中断和重启
考虑如下场景。
1. 为某信号创建处理器函数。
2. 发起一个阻塞的系统调用(blocking system call),例如,从终端设备调用的read()就会阻塞到有数据输入为止。
3. 当系统调用遭到阻塞时,之前创建了处理器函数的信号传递了过来,随即引发对处理器函数的调用。
信号处理器返回后又会发生什么?默认情况下,系统调用失败,并将errno置为EINTR。这是一种有用的特性。如使用定时器(会产生SIGALRM信号)来设置像read()之类阻塞系统调用的超时。
不过,更为常见的情况是希望遭到中断的系统调用得以继续运行。为此,可在系统调用遭信号处理器中断的事件中,利用如下代码来手动重启系统调用。
/*通过errno值判断是否被中断,然后决定是否继续执行被打断的系统调用*/
while ((cnt = read(fd, buf, BUF_SIZE) == -1 && errno == EINTR))
continue;
如果该代码频繁使用:
#define NO_EINTR(SMT) while ((smt) == -1 && errno == EINTR);
使用该宏,可以将早先对read()的调用改写如下 :
NO_EINTR(cnt=read(fd,buf,BUF_SIZE));
if(cnt==-1)
printf("read");
GNU C库提供了一个(非标准)宏,其作用与定义于<unistd.h>中的NO_EINTR()相同。该宏名为TEMP_FAILURE_RETRY(),定义特性测试宏_GNU_SOURCE后即可使用。
即使采用了类似NO_EINTR()这样的宏,让信号处理器来中断系统调用还是颇为不便,因为只要有意重启阻塞的调用,就需要为每个阻塞的系统调用添加代码。反之,可以调用指定了SA_RESTART标志的sigaction()来创建信号处理器函数,从而令内核代表进程自动重启系统调用,还无需处理系统调用可能返回的EINTR错误。
标志SA_RESTART是针对每个信号的设置。换言之,允许某些信号的处理器函数中断阻塞的系统调用,而其他系统调用则可以自动重启。
不幸的是,并非所有的系统调用都可以通过指定SA_RESTART来达到自动重启的目的。究其原因,有部分历史因素。
- 4.2BSD引入了重启系统调用的概念,包括中断对wait()和waitpid()的调用,以及如下I/O系统调用:read()、readv()、write()和阻塞的ioctl()操作。I/O系统调用都是可中断的,所以只有在操作“慢速(slow)”设备时,才可以利用SA_RESTART标志来自动重启调用。慢速设备包括终端(terminal)、管道(pipe)、FIFO以及套接字(socket)。对于这些文件类型,各种I/O操作都有可能堵塞。(相比之下,磁盘文件并未沦入慢速设备之列,因为借助于缓冲区高速缓存,磁盘I/O请求一般都可以立即得到满足。当出现磁盘I/O请求时,内核会令该进程休眠,直至完成I/O动作为止。)
- 其他大量阻塞的系统调用则继承自System V,在其初始设计中并未提供重启系统调用的功能。
- 用来等待子进程(26.1节)的系统调用:wait()、waitpid()、wait3()、wait4()和waitid()。
- 访问慢速设备时的I/O系统调用:read()、readv()、write()、writev()和ioctl()。如果在收到信号时已经传递了部分数据,那么还是会中断输入输出系统调用,但会返回成功状态:一个整型值,表示已成功传递数据的字节数。
- 系统调用open(),在可能阻塞的情况下(例如,如44.7节所述,在打开FIFO时)。
- 用于套接字的各种系统调用:accept()、accept4()、connect()、send()、sendmsg()、sendto()、recv()、recvfrom()和recvmsg()。(在Linux中,如果使用setsockopt()来设置超时,这些系统调用就不会自动重启。更多细节请参考signal(7)手册页。)
- 对POSIX消息队列进行I/O操作的系统调用:mq_receive()、mq_timedreceive()、mq_send()和mq_timedsend()。
- 用于设置文件锁的系统调用和库函数:flock()、fcntl()和lockf()。
- Linux特有系统调用futex()的FUTEX_WAIT操作。
- 用于递减POSIX信号量的sem_wait()和sem_timedwait()函数。(在一些UNIX实现上,如果设置了SA_RESTART标志,sem_wait()就会重启。)
- 用于同步POSIX线程的函数:pthread_mutex_lock()、pthread_mutex_trylock()、pthread_mutex_timedlock()、pthread_cond_wait()和pthread_cond_timedwait()。
内核2.6.22之前,不管是否设置了SA_RESTART标志,futex()、sem_wait()和sem_timedwait()遭到中断时总是产生EINTR错误。
以下阻塞的系统调用(以及构建于其上的库函数)则绝不会自动重启(即便指定了SA_RESTART)。
- poll()、ppoll()、select()和pselect()这些I/O多路复用调用。(SUSv3明文规定,无论设置SA_RESTART标志与否,都不对select()和pselect()遭处理器函数中断时的行为进行定义。)
- Linux特有的epoll_wait()和epoll_pwait()系统调用。
- Linux特有的io_getevents()系统调用。
- 操作System V消息队列和信号量的阻塞系统调用:semop()、semtimedop()、msgrcv()和msgsnd()。(虽然System V原本并未提供自动重启系统调用的功能,但在某些UNIX实现上,如果设置了SA_RESTART标志,这些系统调用还是会自动重启。)
- 对inotify文件描述符发起的read()调用。
- 用于将进程挂起指定时间的系统调用和库函数:sleep()、nanosleep()和clock_nanosleep()。
- 特意设计用来等待某一信号到达的系统调用:pause()、sigsuspend()、sigtimedwait()和sigwaitinfo()。
为信号修改SA_RESTART标志
函数siginterrupt()用于改变信号的SA_RESTART设置。
#include<signal.h>
int siginterrupt(int sig, int flag);
若参数flag为真(1),则针对信号sig的处理器函数将会中断阻塞的系统调用的执行。如果flag为假(0),那么在执行了sig的处理器函数之后,会自动重启阻塞的系统调用。
SUSv4标记sigterrupt()为已废止,并推荐使用sigaction()加以替代。
核心转储文件
特定信号会引发进程创建一个核心转储文件并终止运行。所谓核心转储是内含进程终止时内存映像的一个文件。(术语core源于一种老迈的内存技术。)将该内存映像加载到调试器中,即可查明信号到达时程序代码和数据的状态。
TODO.
实时信号
定义于POSIX.1b中的实时信号,意在弥补对标准信号的诸多限制。较之于标准信号,其优势如下所示。
- 实时信号的信号范围有所扩大,可应用于应用程序自定义的目的。而标准信号中可供应用随意使用的信号仅有两个:SIGUSR1和SIGUSR2。
- 对实时信号所采取的是队列化管理。如果将某一实时信号的多个实例发送给一进程,那么将会多次传递信号。相反,如果某一标准信号已经在等待某一进程,而此时即使再次向该进程发送此信号的实例,信号也只会传递一次。
- 当发送一个实时信号时,可为信号指定伴随数据(一整型数或者指针值),供接收进程的信号处理器获取。
- 不同实时信号的传递顺序得到保障。如果有多个不同的实时信号处于等待状态,那么将率先传递具有最小编号的信号。换言之,信号的编号越小,其优先级越高。如果是同一类型的多个信号在排队,那么信号(以及伴随数据)的传递顺序与信号发送来时的顺序保持一致。
SUSv3要求,实现所提供的各种实时信号不得少于_POSIX_RTSIG_MAX(定义为8)个。Linux内核则定义了32个不同的实时信号,编号范围为 32~63。<signal.h>头文件所定义的RTSIG_MAX常量则表征实时信号的可用数量,而此外所定义的常量SIGRTMIN和SIGRTMAX则分别表示可用实时信号编号的最小值和最大值。
对排队实时信号的数量限制
SUSv3允许实现为每个进程中可排队的(各类)实时信号数量设置上限,并要求其不得少于_POSIX_SIGQUEUE_MAX(定义为32)。实现可借助于对SIGQUEUE_MAX常量的定义来表示其所允许的排队实时信号数量。
发送实时信号
系统调用sigqueue()将由sig指定的实时信号发送给由pid指定的进程。
#define _POSIX_C_SOURCE 199309
#include<signal.h>
int sigqueue(pid_t pid,int sig,const union sigval value);
//成功返回0,失败返回-1
使用sigqueue()发送信号所需要的权限与kill()的要求一致。也可以发送空信号(即信号0),其语义与kill()中的含义相同。(不同于kill(),sigqueue()不能通过将pid指定为负值而向整个进程组发送信号。)
一旦触及对排队信号的数量限制,sigqueue()调用将会失败。同时将errno置为EAGAIN,以示需要再次发送该信号(在当前队列中某些信号传递之后的某一时间点)。
处理实时信号
可以像标准信号一样,使用常规(单参数)信号处理器来处理实时信号。sigaction
定时器与休眠
定时器是进程规划自己在未来某一时刻接获通知的一种机制。
间隔定时器
系统调用setitimer()创建一个间隔式定时器(interval timer),这种定时器会在未来某个时间点到期,并于此后(可选择地)每隔一段时间到期一次。
#include<sys/time.h>
int setitimer(int which, const struct itimerval *new_value,struct itimerval *old_value);
通过在调用setitimer()时为which指定以下值,进程可以创建3种不同类型的定时器。
ITIMER_REAL
创建以真实时间倒计时的定时器。到期时会产生SIGALARM信号并发送给进程.
ITIMER_VIRTUAL
创建以进程虚拟时间(用户模式下的CPU时间)倒计时的定时器。到期时会产生信号SIGVTALRM。
ITIMER_PROF
创建一个profiling定时器,以进程时间(用户态与内核态CPU时间的总和)倒计时。到期时,则会产生SIGPROF信号。
参数new_value和old_value均为指向结构itimerval的指针,结构的定义如下:
struct itimerval {
struct timerval it_interval; //设为时钟间隔
struct timerval it_value; //设为第一次触发的时钟间隔,只被执行一次
};
struct timeval {
long tv_sec; //秒
long tv_usec; //微秒
};
数new_value的下属结构it_value指定了距离定时器到期的延迟时间。另一下属结构it_interval则说明该定时器是否为周期性定时器。如果it_interval的两个字段值均为0,那么该定时器就属于在it_value所指定的时间间隔后到期的一次性定时器。只要it_interval中的任一字段非0,那么在每次定时器到期之后,都会将定时器重置为在指定间隔后再次到期。
进程只能拥有上述3种定时器中的一种。当第2次调用setitimer()时,修改已有定时器的属性要符合参数which中的类型。如果调用setitimer()时将new_value.it_value的两个字段均置为0,那么会屏蔽任何已有的定时器。
若参数old_value不为NULL,则以其所指向的itimerval结构来返回定时器的前一设置。如果old_value.it_value的两个字段值均为0,那么该定时器之前处于屏蔽状态。如果old_value.it_interval的两个字段值均为0,那么该定时器之前被设置为历经old_value.it_value指定时间而到期的一次性定时器。对于需要在新定时器到期后将其还原的情况而言,获取定时器的前一设置就很重要。如果不关心定时器的前一设置,可以将old_value置为NULL。
可以在任何时刻调用getitimer(),以了解定时器的当前状态、距离下次到期的剩余时间。
#include<sys/time.h>
int getitimer(int which, struct itimerval *value);
系统调用getitimer()返回由which指定定时器的当前状态,并置于由curr_value所指向的缓冲区中。这与setitimer()借参数old_value所返回的信息完全相同,区别则在于getitimer()无需为了获取这些信息而改变定时器的设置。
更为简单的定时器接口:alarm()
#include<unistd.h>
unsigned int alarm(unsigned int seconds);
//总是调用成功
参数seconds表示定时器到期的秒数。到期时,会向调用进程发送SIGALRM信号。
调用alarm()会覆盖对定时器的前一个设置。调用alarm(0)可屏蔽现有定时器。
alarm()的返回值是定时器前一设置距离到期的剩余秒数,如未设置定时器则返回0。
setitimer()和alarm()之间的交互
Linux中,alarm()和setitimer()针对同一进程(per-process)共享同一实时定时器,为了确保应用程序可移植性的最大化,程序设置实时定时器的函数只能在二者中选择其一。
定时器的调度及精度
取决于当前负载和对进程的调度,系统可能会在定时器到期的瞬间(通常是几分之一秒)之后才去调度其所属进程。尽管如此,由setitimer()或其他接口所创建的周期性定时器,在到期后依然会恪守其规律性。例如,假设设置一个实时定时器每两秒到期一次,虽然上述延迟可能会影响每个定时器事件的送达,但系统对后续定时器到期的调度依然会严格遵循两秒的时间间隔。换言之,间隔式定时器不受潜在错误左右。
对于现代Linux内核而言,适才关于定时器分辨率受限于软件时钟频率的论断已经不再成立。自版本2.6.21开始,Linux内核可选择是否支持高分辨率定时器。如果选择支持(通过内核配置选项CONFIG_HIGH_RES_TIMERS),那么本章各种定时器以及休眠接口的的精度则不再受内核jiffy(软件时钟周期)的影响,可以达到底层硬件所支持的精度。在现代硬件平台上,精度达到微秒级是司空见惯的事情。
为阻塞操作设置超时
#include<signal.h>
#include<iostream>
#include <unistd.h>
#include<string>
using namespace std;
void handler(int sig)
{
cout<<"hello"<<endl;
}
int main()
{
printf("124\n");
struct sigaction sa;
sa.sa_handler=handler;
sigaction(SIGALRM,&sa,NULL);
alarm( 5 );
string test;
cin>>test;
printf("end\n");
return 0;
}
124
hello
end
如果不调用sigaction,则显示:
124
Alarm clock
暂停运行(休眠)一段固定时间
低分辨率休眠:sleep()
函数sleep()可以暂停调用进程的执行达数秒之久(由参数seconds设置),或者在捕获到信号(从而中断调用)后恢复进程的运行。
#include<unistd.h>
unsigned int sleep(unsigned int seconds);
如果休眠正常结束,sleep()返回0。如果因信号而中断休眠,sleep()将返回剩余(未休眠)的秒数。与alarm()和setitimer()所设置的定时器相同,由于系统负载的原因,内核可能会在完成sleep()的一段(通常很短)时间后才对进程重新加以调度。
Linux将sleep()实现为对nanosleep()的调用,其结果是sleep()与定时器函数之间并无交互。
考虑到可移植性,应避免将sleep()和alarm()以及setitimer()混用。
高分辨率休眠:nanosleep()
函数nanosleep()的功用与sleep()类似,但更具优势,其中包括能以更高分辨率来设定休眠间隔时间。
#include <time.h>
int nanosleep(const struct timespec *request, struct timespec *remain);
struct timespec{
time_t tv_sec; //秒
long tv_usec; //纳秒,取值范围在0~999999999之间。
};
参数request指定了休眠的持续时间。
nanosleep()的更大优势在于,SUSv3明文规定不得使用信号来实现该函数。这意味着,与sleep()不同,即使将nanosleep()与alarm()或setitimer()混用,也不会危及程序的可移植性。
POSIX时钟
POSIX时钟(原定义于POSIX.1b)所提供的时钟访问API可以支持纳秒级的时间精度,其中表示纳秒级时间值的timespec结构同样也用于nanosleep()调用。
Linux中,调用此API的程序必须以-lrt选项进行编译,从而与librt(realtime,实时)函数库相链接。
POSIX时钟API的主要系统调用包括获取时钟当前值的clock_gettime()、返回时钟分辨率的clock_getres(),以及更新时钟的clock_settime()。
获取时钟的值:clock_gettime()
系统调用clock_gettime()针对参数clockid所指定的时钟返回时间。
#define _POSIX_C_SOURCE 199309
#include<time.h>
int clock_gettime(clockid_t clockid,struct timespec *tp);
int clock_getres(clockid_t clockid,struct timespec *res);
//成功返回0,失败返回—1
返回的时间值置于tp指针所指向的timespec结构中。虽然timespec结构提供了纳秒级精度,但clock_gettime()返回的时间值粒度可能还是要更大一点。系统调用clock_getres()在参数res中返回指向timespec结构的指针,机构中包含了由clockid所指定时钟的分辨率。
clockid_t是一种由SUSv3定义的数据类型,用于表示时钟标识符。
clockid_t | 描 述 |
---|---|
CLOCK_REALTIME | 可设定的系统级实时时钟 |
CLOCK_MONOTONIC | 不可设定的恒定态时钟 |
CLOCK_PROCESS_CPUTIME_ID | 每进程CPU时间的时钟(自Linux 2.6.12) |
CLOCK_THREAD_CPUTIME_ID | 每线程CPU时间的时钟(自Linux 2.6.12) |
CLOCK_REALTIME时钟是一种系统级时钟,用于度量真实时间。与CLOCK_MONOTONIC时钟不同,它的设置是可以变更的。
SUSv3规定,CLOCK_MONOTONIC时钟对时间的度量始于“未予规范的过去某一时点”,系统启动后就不会发生改变。该时钟适用于那些无法容忍系统时钟发生跳跃性变化(例如:手工改变了系统时间)的应用程序。Linux上,这种时钟对时间的测量始于系统启动。
CLOCK_PROCESS_CPUTIME_ID 时钟测量调用进程所消耗的用户和系统 CPU 时间。CLOCK_THREAD_CPUTIME_ID时钟的功用与之相类似,不过测量对象是进程中的单条线程。
设置时钟的值:clock_settime()
系统调用clock_settime()利用参数tp所指向缓冲区中的时间来设置由clockid指定的时钟。
#define _POSIX_C_SOURCE 199309
#include<time.h>
int clock_settime(clockid_t clockid,const struct timespect *tp);
//成功返回0,失败返回-1
如果由tp指定的时间并非由clock_getres()所返回时钟分辨率的整数倍,时间会向下取整。
特权级(CAP_SYS_TIME)进程可以设置CLOCK_REALTIME时钟。该时钟的初始值通常是自Epoch(1970年1月1日0点0分0秒)以来的时间。
获取特定进程或线程的时钟ID
要测量特定进程或线程所消耗的CPU时间,首先可借助本节所描述的函数来获取其时钟ID。接着再以此返回id去调用clock_gettime(),从而获得进程或线程耗费的CPU时间。
函数clock_getcpuclockid()会将隶属于pid进程的CPU时间时钟的标识符置于clockid指针所指向的缓冲区中。
#define _XOPEN_SOURCE 600
#include<time.h>
int clock_getcpuclockid(pid_t pid,clockid_t *clockid);
//成功返回0,失败返回-1
参数pid为0时,clock_getcpuclockid()返回调用进程的CPU时间时钟ID。
函数pthread_getcpuclockid()是clock_getcpuclockid()的POSIX线程版,返回的标识符所标识的时钟用于度量调用进程中指定线程消耗的CPU时间。
#define _XOPEN_SOURCE 600
#include<pthread.h>
#include<time.h>
int pthread_getcpuclockid(pthread_t thread,clockid_t *clockid);
//成功返回0,失败返回-1
参数thread是POSIX线程ID,用于指定希望获取的CPU时钟ID所从属的线程。返回的时钟ID存放于clockid指针所指向的缓冲区中。
高分辨率休眠的改进版:clock_nanosleep()
类似于nanosleep(),Linux特有的clock_nanosleep()系统调用也可以暂停调用进程,直到历经一段指定的时间间隔后,亦或是收到信号才恢复运行。
#include <time.h>
int clock_nanosleep(clockid_t clockid, int flags, const struct timespec *request, struct timespec *remain);
//返回值:若休眠到要求的时间,返回0;若出错,返回错误码
参数request及remain同nanosleep()中的对应参数目的相似。
TODO.
POSIX间隔式定时器
使用setitimer()来设置经典UNIX间隔式定时器,会受到如下制约。
- 针对ITIMER_REAL、ITIMER_VIRTUAL和ITIMER_PROF这3类定时器,每种只能设置一个。
- 只能通过发送信号的方式来通知定时器到期。另外,也不能改变到期时产生的信号。
- 如果一个间隔式定时器到期多次,且相应信号遭到阻塞时,那么会只调用一次信号处理器函数。换言之,无从知晓是否出现过定时器溢出(timer overrun)的情况。
- 定时器的分辨率只能达到微秒级。不过,一些系统的硬件时钟提供了更为精细的时钟分辨率,软件此时应采用这一较高分辨率。
POSIX.1b定义了一套API来突破这些限制.
POSIX定时器API将定时器生命周期划分为如下几个阶段。
- 以系统调用timer_create()创建一个新定时器,并定义其到期时对进程的通知方法。
- 以系统调用timer_settime()来启动或停止一个定时器。
- 以系统调用timer_delete()删除不再需要的定时器。
由fork()创建的子进程不会继承POSIX定时器。调用exec()期间亦或进程终止时将停止并删除定时器。
Linux上,调用POSIX定时器API的程序编译时应使用-lrt
选项,从而与librt(实时)函数库相链接。
创建定时器:timer_create()
函数timer_create()创建一个新定时器,并以由clockid指定的时钟来进行时间度量。
#define _POSIX_C_SOURCE 199309
#include <signal.h>
#include <time.h>
int timer_create(clockid_t clockid, struct sigevent *evp, timer_t *timerid);
//成功返回0,失败返回-1
参数evp可决定定时器到期时对应用程序的通知方式,指向类型为sigevent的数据结构,具体定义如下:
union sigval{
int sival_int; //整数值
void *sival_ptr; //指针值
};
struct sigevent{
int sigev_notify; //通知方式
int sigev_signo; //定时器溢出信号
union sigval sigev_value; //信号值
union{
pid_t _tid; //ID of thread to be signaled
struct {
void(*_function)(union sigval);//线程通知函数
void *_attribute; //真正的“pthread_attr_t”
}_sigev_thread;
}_sigev_un;
};
#define sigev_notify_function _sigev_un._sigev_thread._function
#define sigev_notify_attributes _sigev_un._sigev_thread._attribute
#define sigev_notify_thread_id _sigev_un._tid
sigev_notify | 解释 |
---|---|
SIGEV_NONE | 不通知;使用timer_gettime()监测定时器 |
SIGEV_SIGNAL | 发送sigev_signo信号给进程 |
SIGEV_THREAD | 调用sigev_notify_function作为新线程的启动函数 |
SIGEV_THREAD_ID | 发送sigev_signo信号给 sigev_notify_thread_id所标识的线程 |
配备和解除定时器:timer_settime()
一旦创建了定时器,就可以使用timer_settime()对其进行配备(启动)或解除(停止)。
#define _POSIX_C_SOURCE 199309
#include <time.h>
int timer_settime(timer_t *timerid,int flags,cosnt struct itimerspec *value,struct itimerspec *old_value);
//成功返回0,失败返回-1
struct itimerspec{
struct timespec it_interval; //中断周期
struct timespec it_value; //第一次执行时间
};
struct timespec{
time_t tv_sec; //秒
long tv_nsec; //纳秒
};
函数timer_settime()的参数timerid是一个定时器句柄(handle),由之前对timer_create()的调用返回。
获取定时器的当前值:timer_gettime()
系统调用timer_gettime()返回由timerid指定POSIX定时器的间隔以及剩余时间。
#define _POSIX_C_SOURCE 199309
#include <time.h>
int timer_gettime(timer_t *timerid,struct itimerspec *curr_value);
//成功返回0,失败返回-1
curr_value指针所指向的itimerspec结构中返回的是时间间隔以及距离下次定时器到期的时间。即使是以TIMER_ABSTIME标志创建的绝对时间定时器,在curr_value.it_value字段中返回的也是距离定时器下次到期的时间值。
如果返回结构curr_value.it_value的两个字段均为0,那么定时器当前处于停止状态。如果返回结构curr_value.it_interval的两个字段都是0,那么该定时器仅在curr_value.it_value给定的时间到期过一次。
删除定时器:timer_delete()
每个POSIX定时器都会消耗少量系统资源。所以,一旦使用完毕,应当用timer_delete()来移除定时器并释放这些资源。
#define _POSIX_C_SOURCE 199309
#include <time.h>
int timer_delete(timer_t *timerid);
//成功返回0,失败返回-1
参数timerid是之前调用timer_create()时返回的句柄。对于已启动的定时器,会在移除前自动将其停止。如果因定时器到期而已经存在待定(pending)信号,那么信号会保持这一状态。(SUSv3对此并未加以规范,所以其他的一些UNIX实现可能会有不同行为。)当进程终止时,会自动删除所有定时器。
通过信号发出通知
todo.
定时器溢出
假设已经选择通过信号(即sigev_notify为SIGEV_SIGNAL)传递的方式来接收定时器到期通知。进一步假设,在捕获或接收相关信号之前,定时器到期多次。这可能是因为进程再次获得调度前的延时所致。另外,不论是直接调用sigprocmask(),还是在信号处理器函数里暗中处理,也都有可能堵塞相关信号的发送。如何知道发生了这些定时器溢出呢?
也许会认为使用实时信号有助于解决这个问题,因为可以对实时信号的多个实例进行排队。不过,由于对排队实时信号有数量上的限制,结果证明这种方法也无法奏效。所以POSIX.1b委员会选用了另一种方法:一旦选择通过信号来接收定时器通知,那么即便用了实时信号,也绝不会对该信号的多个实例进行排队。相反,在接收信号后(无论是通过信号处理器函数还是调用sigwaitinfo()),可以获取定时器溢出计数,即在信号生成与接收之间发生的定时器到期额外次数。如果上次收到信号后定时器发生了3次到期,那么溢出计数是2。
接收到定时器信号之后,有两种方法可以获取定时器溢出值。
- 调用timer_getoverrun()。这是由SUSv3指定去获取溢出计数的方法。
- 使用随信号一同返回的结构 siginfo_t 中的 si_overrun 字段值。这种方法可以避免timer_getoverrun()的系统调用开销,但同时也是一种Linux扩展方法,无法移植。
每次收到定时器信号后,都会重置定时器溢出计数。若自处理或接收定时器信号之后,定时器仅到期一次,则溢出计数为0(即无溢出)。
#define _POSIX_C_SOURCE 199309
#include<time.h>
int timer_getoverrun(timer_t timerid);
//成功返回溢出次数,失败返回-1
函数timer_getoverrun()返回由参数timerid指定定时器的溢出值。
根据SUSv3规定,函数timer_getoverrun()是异步信号安全的函数之一,故而在信号处理器函数内部调用也是安全的。
通过线程来通知
TODO.
利用文件描述符进行通知的定时器:timerfd API
始于版本2.6.25,Linux内核提供了另一种创建定时器的API。Linux特有的timerfd API,可从文件描述符中读取其所创建定时器的到期通知。因为可以使用select()、poll()和epoll()将这种文件描述符会同其他描述符一同进行监控,所以非常实用。(至于说本章讨论的其他定时器API,想要把一个或多个定时器与一组文件描述符放在一起同时监测,可不是件容易的事。)
新加入的第1个系统调用是timerfd_create(),它会创建一个新的定时器对象,并返回一个指代该对象的文件描述符。
#include<sys/timerfd.h>
int timerfd_create(int clockid,int flags);
//成功返回文件描述符,失败返回-1
参数clockid的值可以设置为CLOCK_REALTIME或CLOCK_MONOTONIC.
timerfd_create()的最初实现将参数flags预留供未来使用,必须设置为0。不过,Linux内核从2.6.27版本开始支持下面两种flags标志。
TFD_CLOEXEC
为新的文件描述符设置运行时关闭标志(FD_CLOEXEC)。与4.3.1节介绍的open()标志O_CLOEXEC适用于相同情况。
TFD_NONBLOCK
为底层的打开文件描述设置O_NONBLOCK标志,随后的读操作将是非阻塞式的。这样设置省却了对fcntl()的额外调用,却能达到相同效果。
timerfd_create()创建的定时器使用完毕后,应调用close()关闭相应的文件描述符,以便于内核能够释放与定时器相关的资源。
系统调用timerfd_settime()可以配备(启动)或解除(停止)由文件描述符fd所指代的定时器。
#include<sys/timerfd.h>
int timerfd_settime(int fd,int flags,const struct itimerspec *new_value,struct itimerspec *old_value);
//成功返回文件描述符,失败返回-1
系统调用timerfd_gettime()返回文件描述符fd所标识定时器的间隔及剩余时间。
#include<sys/timerfd.h>
int timerfd_gettime(int fd,struct itimerspec *curr_value);
//成功返回文件描述符,失败返回-1
timerfd与fork()及exec()之间的交互
调用fork()期间,子进程会继承timerfd_create()所创建文件描述符的拷贝。这些描述符与父进程的对应描述符均指代相同的定时器对象,任一进程都可读取定时器的到期信息。
timerfd_create()创建的文件描述符能跨越exec()得以保存(除非将描述符置为运行时关闭),已配备的定时器在exec()之后会继续生成到期通知。
从timerfd文件描述符读取
一旦以timerfd_settime()启动了定时器,就可以从相应文件描述符中调用read()来读取定时器的到期信息。出于这一目的,传给read()的缓冲区必须足以容纳一个无符号8字节整型(uint64_t)数。
进程:进程及进程的创建
在诸多应用中,创建多个进程是任务分解时行之有效的方法。
例如,某一网络服务器进程可在侦听客户端请求的同时,为处理每一请求而创建一新的子进程,与此同时,服务器进程会继续侦听更多的客户端连接请求。以此类手法分解任务,通常会简化应用程序的设计,同时提高了系统的并发性。(即,可同时处理更多的任务或请求。)
进程和程序
进程(process)是一个可执行程序(program)的实例。
- 二进制格式标识:每个程序文件都包含用于描述可执行文件格式的元信息(metainformation)。内核(kernel)利用此信息来解释文件中的其他信息。历史上,UNIX可执行文件曾有两种广泛使用的格式,分别为最初的a.out(汇编程序输出)和更加复杂的COFF(通用对象文件格式)。现在,大多数UNIX实现(包括Linux)采用可执行连接格式(ELF),这一文件格式比老版本格式具有更多优点。
- 机器语言指令:对程序算法进行编码。
- 程序入口地址:标识程序开始执行时的起始指令位置。
- 数据:程序文件包含的变量初始值和程序使用的字面常量(literal constant)值(比如字符串)。
- 符号表及重定位表:描述程序中函数和变量的位置及名称。这些表格有多种用途,其中包括调试和运行时的符号解析(动态链接)。
- 共享库和动态链接信息:程序文件所包含的一些字段,列出了程序运行时需要使用的共享库,以及加载共享库的动态链接器的路径名。
- 其他信息:程序文件还包含许多其他信息,用以描述如何创建进程。
进程号
可以用一个程序来创建许多进程。
一旦进程号达到32767,会将进程号计数器重置为300,而不是1。之所以如此,是因为低数值的进程号为系统进程和守护进程所长期占用,在此范围内搜索尚未使用的进程号只会是浪费时间。
虚拟内存管理
虚拟内存的实现需要硬件中分页内存管理单元(PMMU)的支持。PMMU把要访问的每个虚拟内存地址转换成相应的物理内存地址,当特定虚拟内存地址所对应的页没有驻留于RAM中时,将以页面错误通知内核。
环境列表
每一个进程都有与其相关的称之为环境列表(environment list)的字符串数组,或简称为环境(environment)。其中每个字符串都以名称=值(name=value)形式定义。因此,环境是“名称-值”的成对集合,可存储任何信息。常将列表中的名称称为环境变量(environment variables)。
新进程在创建之时,会继承其父进程的环境副本。这是一种原始的进程间通信方式,却颇为常用。环境(environment)提供了将信息从父进程传递给子进程的方法。由于子进程只有在创建时才能获得其父进程的环境副本,所以这一信息传递是单向的、一次性的。子进程创建后,父、子进程均可更改各自的环境变量,且这些变更对对方而言不再可见。
printenv访问环境变量
在bash中使用printenv显示环境变量:
printenv
TERM_SESSION_ID=w0t0p0:F066B8F5-6D43-4D2E-BA82-8899DC1EF600
SSH_AUTH_SOCK=/private/tmp/com.apple.launchd.babpyp7kOs/Listeners
LC_TERMINAL_VERSION=3.3.8
COLORFGBG=15;0
ITERM_PROFILE=Default
XPC_FLAGS=0x0
LANG=zh_CN.UTF-8
PWD=/Users/cdq
SHELL=/bin/zsh
TERM_PROGRAM_VERSION=3.3.8
TERM_PROGRAM=iTerm.app
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
LC_TERMINAL=iTerm2
COLORTERM=truecolor
TERM=xterm-256color
HOME=/Users/cdq
TMPDIR=/var/folders/h1/hdbffw8540v54tbz5ll9lz5m0000gn/T/
USER=cdq
XPC_SERVICE_NAME=0
LOGNAME=cdq
ITERM_SESSION_ID=w0t0p0:F066B8F5-6D43-4D2E-BA82-8899DC1EF600
__CF_USER_TEXT_ENCODING=0x0:25:52
SHLVL=1
OLDPWD=/Users/cdq
ZSH=/Users/cdq/.oh-my-zsh
PAGER=less
LESS=-R
LSCOLORS=Gxfxcxdxbxegedabagacad
AUTOJUMP_SOURCED=1
AUTOJUMP_ERROR_PATH=/Users/cdq/Library/autojump/errors.log
_=/usr/bin/printenv
访问environ变量来展示该进程环境中的所有值
#include <iostream>
using namespace std;
extern char **environ;
int main(int argc, char *argv[])
{
char **ep;
for(ep=environ;*ep!=NULL;ep++)
{
puts(*ep);
}
return 0;
}
效果和上不步一样。
getenv()获得单个环境变量
#include <iostream>
#include<unistd.h>
using namespace std;
extern char **environ;
int main(int argc, char *argv[])
{
char **ep;
cout<<getenv("PATH")<<endl;
return 0;
}
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
设置环境变量
setenv()函数向调用进程的环境中添加一个新变量,或者修改一个已经存在的变量值。
#include<stdlib.h>
int setenv(const char *name,const char *value,int overwrite);
//成功返回0,失败返回非0值
若以name标识的变量在环境中已经存在,且参数overwrite的值为0,则setenv()函数将不改变环境,如果参数overwrite的值为非0,则setenv()函数总是改变环境。
unsetenv()函数从环境中移除由name参数标识的变量。
#include<stdlib.h>
int unsetenv(const char *name);
//成功返回0,失败返回非0值
执行非局部跳转:setjmp()和longjmp()
使用库函数setjmp()和longjmp()可执行非局部跳转(nonlocal goto)。术语“非局部(nonlocal)”是指跳转的目标为当前执行函数之外的某个位置。
C语言,像许多其他编程语言一样,包含goto语句。这就好比打开了潘多拉的魔盒。若无止境的滥用,将使程序难以阅读和维护。不过偶尔也能一显身手,令程序更简单、更快速,或是兼而有之。
C语言的goto语句存在一个限制,即不能从当前函数跳转到另一函数。然而,偶尔还是需要这一功能的。考虑错误处理中经常出现的如下场景:在一个深度嵌套的函数调用中发生了错误,需要放弃当前任务,从多层函数调用中返回,并在较高层级的函数中继续执行(也许甚至是在main()中)。要做到这一点,可以让每个函数都返回一个状态值,由函数的调用者检查并做相应处理。这一方法完全有效,而且,在许多情况下,是处理这类场景的理想方法。然而,有时候如果能从嵌套函数调用中跳出,返回该函数的调用者之一(当前调用者或者调用者的调用者,等等),编码会更为简单。setjmp()和longjmp()就提供了这一功能。
TODO.
进程凭证
每个进程都有一套用数字表示的用户ID ① (UID)和组ID(GID)。有时,也将这些ID称之为进程凭证。具体如下所示。
实际用户ID(real user ID)和实际组ID(real group ID)。
有效用户ID(effective user ID)和有效组ID(effective group ID)。
保存的set-user-ID(saved set-user-ID)和保存的set-group-ID(saved set-group-ID)。
文件系统用户ID(file-system user ID)和文件系统组ID(file-system group ID)(Linux专有)。
辅助组ID。
进程的创建
fork()、exit()、wait()以及execve()的简介
fork():
系统调用fork()允许一进程(父进程)创建一新进程(子进程)。具体做法是,新的子进程几近于对父进程的翻版:子进程获得父进程的栈、数据段、堆和执行文本段(6.3节)的拷贝。可将此视为把父进程一分为二,术语fork也由此得名。
exit():
库函数exit(status)终止一进程,将进程占用的所有资源(内存、文件描述符等)归还内核,交其进行再次分配。参数status为一整型变量,表示进程的退出状态。父进程可使用系统调用wait()来获取该状态。
wait():
系统调用wait(&status)的目的有二:其一,如果子进程尚未调用 exit()终止,那么wait()会挂起父进程直至子进程终止;其二,子进程的终止状态通过wait()的status参数返回。
execve():
系统调用 execve(pathname,argv,envp)加载一个新程序(路径名为 pathname,参数列表为 argv,环境变量列表为 envp)到当前进程的内存。这将丢弃现存的程序文本段,并为新程序重新创建栈、数据段以及堆。通常将这一动作称为执行(execing)一个新程序。
图中对execve()的调用并非必须。有时,让子进程继续执行与父进程相同的程序反而会有妙用。最终,两种情况殊途同归:总是要通过调用exit()(或接收一个信号)来终止子进程,而父进程可调用wait()来获取其终止状态。
创建新进程:fork()
在诸多应用中,创建多个进程是任务分解时行之有效的方法。例如,某一网络服务器进程可在侦听客户端请求的同时,为处理每一请求而创建一新的子进程,与此同时,服务器进程会继续侦听更多的客户端连接请求。以此类手法分解任务,通常会简化应用程序的设计,同时提高了系统的并发性。(即,可同时处理更多的任务或请求。)
系统调用fork()创建一新进程(child),几近于对调用进程(parent)的翻版。
#include<unistd.h>
pid_t fork(void);
//在父进程中成功返回子id,失败返回-1
理解fork()的诀窍是,要意识到,完成对其调用后将存在两个进程,且每个进程都会从fork()的返回处继续执行。
这两个进程将执行相同的程序文本段,但却各自拥有不同的栈段、数据段以及堆段拷贝。子进程的栈、数据以及栈段开始时是对父进程内存相应各部分的完全复制。执行 fork()之后,每个进程均可修改各自的栈数据、以及堆段中的变量,而并不影响另一进程。
程序代码则可通过fork()的返回值来区分父、子进程。在父进程中,fork()将返回新创建子进程的进程ID。鉴于父进程可能需要创建,进而追踪多个子进程(通过wait()或类似方法),这种安排还是很实用的。而fork()在子进程中则返回0。如有必要,子进程可调用getpid()以获取自身的进程ID,调用getppid()以获取父进程ID。
当无法创建子进程时,fork()将返回-1。失败的原因可能在于,进程数量要么超出了系统针对此真实用户(real user ID)在进程数量上所施加的限制,要么是触及允许该系统创建的最大进程数这一系统级上限。
调用fork()时,有时会采用如下习惯用语:
pid_t chlidPid;
switch(childPid=fork()){
case -1:
//处理错误
case 0:
//执行子进程动作
default:
//执行父进程
}
调用fork()之后,系统将率先“垂青”于哪个进程(即调度其使用CPU),是无法确定的,意识到这一点极为重要。在设计拙劣的程序中,这种不确定性可能会导致所谓“竞争条件(race condition)”的错误。
例1:
#include<iostream>
#include<unistd.h>
using namespace std;
static int idata = 111; /* Allocated in data segment */
int main(int argc, char *argv[])
{
int istack = 222; /* Allocated in stack segment */
pid_t childPid;
switch (childPid = fork()) {
case -1:
cout<<"fork"<<endl;
case 0:
idata *= 3;
istack *= 3;
break;
default:
sleep(3); /* Give child a chance to execute */
break;
}
/* Both parent and child come here */
printf("PID=%ld %s idata=%d istack=%d\n", (long) getpid(),
(childPid == 0) ? "(child) " : "(parent)", idata, istack);
exit(EXIT_SUCCESS);
}
PID=13443 (child) idata=333 istack=666
PID=13442 (parent) idata=111 istack=222
该程序创建一子进程,并对继承自fork()的全局及自动变量拷贝进行修改。
以上输出表明,子进程在fork()时拥有了自己的栈和数据段拷贝,且其对这些段中变量的修改将不会影响父进程。
父、子进程间的文件共享
执行fork()时,子进程会获得父进程所有文件描述符的副本。这些副本的创建方式类似于dup(),这也意味着父、子进程中对应的描述符均指向相同的打开文件句柄(即 open file description)。打开文件句柄包含有当前文件偏移量(由read()、write()和lseek()修改)以及文件状态标志(由open()设置,通过fcntl()的F_SETFL操作改变)。一个打开文件的这些属性因之而在父子进程间实现了共享。举例来说,如果子进程更新了文件偏移量,那么这种改变也会影响到父进程中相应的描述符。
父子进程间共享打开文件属性的妙用屡见不鲜。例如,假设父子进程同时写入一文件,共享文件偏移量会确保二者不会覆盖彼此的输出内容。不过,这并不能阻止父子进程的输出随意混杂在一起。要想规避这一现象,需要进行进程间同步。比如,父进程可以使用系统调用wait()来暂停运行并等待子进程退出。shell就是这么做的:只有当执行命令的子进程退出后,shell才会打印出提示符(除非用户在命令行最后加上&符以显式在后台运行命令)。
fork()的内存语义
从概念上说来,可以将fork()认作对父进程程序段、数据段、堆段以及栈段创建拷贝。的确,在一些早期的UNIX实现中,此类复制确实是原汁原味:将父进程内存拷贝至交换空间,以此创建新进程映像(image),而在父进程保持自身内存的同时,将换出映像置为子进程。不过,真要是简单地将父进程虚拟内存页拷贝到新的子进程,那就太浪费了。原因有很多,其中之一是:fork()之后常常伴随着 exec(), 这会用新程序替换进程的代码段,并重新初始化其数据段、堆段和栈段。大部分现代UNIX实现(包括Linux)采用两种技术来避免这种浪费。
- 内核(Kernel)将每一进程的代码段标记为只读,从而使进程无法修改自身代码。这样,父、子进程可共享同一代码段。系统调用 fork()在为子进程创建代码段时,其所构建的一系列进程级页表项(page-table entries)均指向与父进程相同的物理内存页帧。
- 对于父进程数据段、堆段和栈段中的各页,内核采用写时复制(copy-on-write)技术来处理。([Bach, 1986]和[Bovert & Cersati, 2005]描述了写时复制的实现。)最初,内核做了一些设置,令这些段的页表项指向与父进程相同的物理内存页,并将这些页面自身标记为只读。调用 fork()之后,内核会捕获所有父进程或子进程针对这些页面的修改企图,并为将要修改的(about-to-be-modified)页面创建拷贝。系统将新的页面拷贝分配给遭内核捕获的进程,还会对子进程的相应页表项做适当调整。从这一刻起,父、子进程可以分别修改各自的页拷贝,不再相互影响。
系统调用vfork()
在早期的BSD实现中,fork()会对父进程的数据段、堆和栈施行严格的复制。如前所述,这是一种浪费,尤其是在调用fork()后立即执行exec()的情况下。出于这一原因,BSD的后期版本引入了vfork()系统调用,尽管其运作含义稍微有些不同(实则有些怪异),但效率要远高于BSD fork()。现代UNIX采用写时复制技术来实现fork(),其效率较之于早期的fork()实现要高出许多,进而将对vfork()的需求剔除殆尽。虽然如此,Linux(如同许多其他的UNIX实现一样)还是提供了具有BSD语义的vfork()系统调用,以期为程序提供尽可能快的fork功能。不过,鉴于vfork()的怪异语义可能会导致一些难以察觉的程序缺陷(bug),除非能给性能带来重大提升(这种情况发生的概率极小),否则应当尽量避免使用这一调用。
fork()之后的竞争条件(Race Condition)
调用 fork()后,无法确定父、子进程间谁将率先访问 CPU。(在多处理器系统中,它们可能会同时各自访问一个 CPU。)就应用程序而言,如果为了产生正确的结果而或明或暗(implicitly or explicitly)地依赖于特定的执行序列,那么将可能因竞争条件而导致失败。由于此类问题的发生取决于内核根据系统当时的负载而做出的调度决定,故而往往难以发现。
其分析结果表明,除去332次之外,都是由父进程先行输出结果(占总数的99.97%)。
依据这一结果可以推测,在Linux 2.2.19中,fork()之后总是继续执行父进程。而子进程之所以在0.03%的情况中首先输出结果,是因为父进程在有机会输出消息之前,其CPU时间片(CPU time slice)就到期了。换言之,如果该程序所代表的情况总是依赖于如下假设,即fork()之后总是调度父进程,那么程序通常可以正常运行,不过每3000次将会出现一次错误。当然,如果希望父进程能在调度子进程前执行大量工作,那么出错的可能性将会大增。在一个复杂程序中调试这样的错误会很困难。
fork()之后对父、子进程的调度谁先谁后?其结果孰优孰劣?最近的一些实验又推翻了内核开发者关于这一问题的评估。从Linux 2.6.32开始,父进程再度成为fork()之后,默认情况下率先调度的对象。将Linux专有文件/proc/sys/kernel/sched_child_runs_first设为非0值可以改变该默认设置。
上述讨论清楚地阐明,不应对fork()之后执行父、子进程的特定顺序做任何假设。若确需保证某一特定执行顺序,则必须采用某种同步技术。例如信号量(semaphore)、文件锁(file lock)以及进程间经由管道(pipe)的消息发送。
同步信号以规避竞争条件
调用fork()之后,如果进程某甲需等待进程某乙完成某一动作,那么某乙(即活动进程)可在动作完成后向某甲发送信号;某甲则等待即可。
进程:进程的终止
进程的终止:_exit()和exit()
通常,进程有两种终止方式。其一为异常(abnormal)终止,由对一信号的接收而引发,该信号的默认动作为终止当前进程,可能产生核心转储(core dump)。此外,进程可使用_exit()系统调用正常(normally)终止。
#include<unistd.h>
void _exit(int status);
_exit()的status参数定义了进程的终止状态(termination status),父进程可调用wait()以获取该状态。虽然将其定义为int类型,但仅有低8位可为父进程所用。按照惯例,终止状态为0表示进程“功成身退”,而非0值则表示进程因异常而退出。对非0返回值的解释则并无定例;不同的应用程序自成一派,并会在文档中加以描述。SUSv3规定有两个常量:EXIT_SUCCESS(0)和EXIT_FAILURE(1),调用_exit()的程序总会成功终止(即,_exit()从不返回)。
虽然可将0~255之间的任意值赋给_exit()的status参数,并传递给父进程,不过如取值大于128将在shell脚本中引发混乱。原因在于,当以信号(signal)终止一命令时,shell 会将变量$?置为 128 与信号值之和,以表征这一事实。如果这与进程调用_exit()时所使用的相同status值混杂起来,将令shell无法区分。
程序一般不会直接调用_exit(),而是调用库函数 exit(),它会在调用_exit()前执行各种动作。
#include<stdlib.h>
void exit(int status);
exit()会执行的动作如下。
- 调用退出处理程序(通过 atexit()和 on_exit()注册的函数),其执行顺序与注册顺序相反。
- 刷新stdio流缓冲区。
- 使用由status提供的值执行_exit()系统调用。
程序的另一种终止方法是从 main()函数中返回(return),或者或明或暗地一直执行到main()函数的结尾处。执行return n等同于执行对exit(n)的调用,因为调用 main()的运行时函数会将 main()的返回值作为 exit()的参数。
进程终止的细节
无论进程是否正常终止,都会发生如下动作。
- 关闭所有打开文件描述符、目录流(18.8节)、信息目录描述符(参考手册页catopen(3)和catgets(3)),以及(字符集)转换描述符(见iconv_open(3)手册页)。
- 作为文件描述符关闭的后果之一,将释放该进程所持有的任何文件锁(第55章)。
- 分离(detach)任何已连接的System V共享内存段,且对应于各段的shm_nattch计数器值将减一。(参考48.8节。)
- 进程为每个System V信号量所设置的semadj值将会被加到信号量值中(参考47.8节)。
- 如果该进程是一个管理终端(terminal)的管理进程,那么系统会向该终端前台(foreground)进程组中的每个进程发送SIGHUP信号,接着终端会与会话(session)脱离。34.6节将就此进行深入讨论。
- 将关闭该进程打开的任何POSIX有名信号量,类似于调用sem_close()。
- 将关闭该进程打开的任何POSIX消息队列,类似于调用mq_close()。
- 作为进程退出的后果之一,如果某进程组成为孤儿,且该组中存在任何已停止进程(stopped processes),则组中所有进程都将收到SIGHUP信号,随之为SIGCONT信号。34.7.4节将深入讨论这一点。
- 移除该进程通过mlock()或mlockall()(50.2节)所建立的任何内存锁。
- 取消该进程调用mmap()所创建的任何内存映射(mapping)。
退出处理程序
有时,应用程序需要在进程终止时自动执行一些操作。试以一个应用程序库为例,如果进程使用了该程序库,那么在进程终止前该库需要自动执行一些清理动作。因为库本身对于进程何时以及如何退出并无控制权,也无法要求主程序在退出前调用库中特定的清理函数,故而也不能保证一定会执行清理动作。解决这一问题的方法之一是使用退出处理程序(exit handler)。老版 System V 手册则使用术语“程序终止过程”(program termination routine)。
退出处理程序是一个由程序设计者提供的函数,可于进程生命周期的任意时点注册,并在该进程调用exit()正常终止时自动执行。如果程序直接调用_exit()或因信号而异常终止,则不会调用退出处理程序。
TODO.
fork()、stdio缓冲区以及_exit()之间的交互
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("Hello world\n");
write(STDOUT_FILENO,"Ciao\n",5);
if(fork()==-1)
printf("fork");
return(0);
}
Hello world
Ciao
以上程序得到了预期的结果。
不过,当重定向标准输出到一个文件时,结果如下:
./a.out >b
cat b
Ciao
Hello world
Hello world
以上输出中有两件怪事:printf()的输出行出现了两次,且write()的输出先于printf()。
要理解为什么printf()的输出消息出现了两次,首先要记住,是在进程的用户空间内存中(参考 13.2节)维护 stdio 缓冲区的。因此,通过fork()创建子进程时会复制这些缓冲区。当标准输出定向到终端时,因为缺省为行缓冲,所以会立即显示函数printf()输出的包含换行符的字符串。不过,当标准输出重定向到文件时,由于缺省为块缓冲,所以在本例中,当调用 fork()时,printf()输出的字符串仍在父进程的 stdio 缓冲区中,并随子进程的创建而产生一份副本。父、子进程调用exit()时会刷新各自的 stdio 缓冲区,从而导致重复的输出结果。
可以采用以下任一方法来避免重复的输出结果。
- 作为针对 stdio 缓冲区问题的特定解决方案,可以在调用 fork()之前使用函数 fflush()来刷新stdio缓冲区。作为另一种选择,也可以使用setvbuf()和setbuf()来关闭stdio流的缓冲功能。
- 子进程可以调用_exit()而非 exit(),以便不再刷新stdio缓冲区。这一技术例证了一个更为通用的原则:在创建子进程的应用中,典型情况下仅有一个进程(一般为父进程)应通过调用 exit()终止,而其他进程应调用_exit()终止,从而确保只有一个进程调用退出处理程序并刷新stdio缓冲区,这也算是众望所归吧。
程序清单中write()的输出并未出现两次,这是因为write()会将数据直接传给内核缓冲区,fork()不会复制这一缓冲区。
程序输出重定向到文件时出的第二件怪事,原因现在也清楚了。write()的输出结果先于printf()而出现,是因为write()会将数据立即传给内核高速缓存,而printf()的输出则需要等到调用exit ()刷新stdio缓冲区时。(如13.7节所述,通常,在混合使用stdio函数和系统调用对同一文件进行I/O处理时,需要特别谨慎。)
进程:监控子进程
等待子进程
对于许多需要创建子进程的应用来说,父进程能够监测子进程的终止时间和过程是很有必要的。wait()以及若干相关的系统调用提供了这一功能。
系统调用wait()
系统调用wait()等待调用进程的任一子进程终止,同时在参数status所指向的缓冲区中返回该子进程的终止状态。
#include <wait.h>
int wait(int *status);
系统调用wait()执行如下动作。
- 如果调用进程并无之前未被等待的子进程终止,调用将一直阻塞,直至某个子进程终止。如果调用时已有子进程终止,wait()则立即返回。
- 如果status非空,那么关于子进程如何终止的信息则会通过status指向的整型变量返回。26.1.3节将讨论自status返回的信息。
- 内核将会为父进程下所有子进程的运行总量追加进程CPU时间(10.7节)以及资源使用数据。
- 将终止子进程的ID作为wait()的结果返回。
出错时,wait()返回-1。可能的错误原因之一是调用进程并无之前未被等待的子进程,此时会将errno置为ECHILD。换言之,可使用如下代码中的循环来等待调用进程的所有子进程退出。
while(childPid = wait(NULL)!= -1)
continue;
if(errno != ECHILD)
printf("wait");
系统调用waitpid()
系统调用wait()存在诸多限制,而设计waitpid()则意在突破这些限制。
- 如果父进程已经创建了多个子进程,使用 wait()将无法等待某个特定子进程的完成,只能按顺序等待下一个子进程的终止。
- 如果没有子进程退出,wait()总是保持阻塞。有时候会希望执行非阻塞的等待:是否有子进程退出,立判可知。
- 使用wait()只能发现那些已经终止的子进程。对于子进程因某个信号(如SIGSTOP或SIG TTIN)而停止,或是已停止子进程收到SIGCONT信号后恢复执行的情况就无能为力了。
#include<sys/wait.h>
pid_t waitpid(pid_t pid,int * status,int options);
//成功返回子进程id,失败返回-1
waitpid()与 wait()的返回值以及参数 status 的意义相同。
参数pid用来表示需要等待的具体子进程,意义如下:
- 如果pid大于0,表示等待进程ID为pid的子进程。
- 如果pid等于0,则等待与调用进程(父进程)同一个进程组(process group)的所有子进程。34.2节将描述进程组的概念。
- 如果pid小于-1,则会等待进程组标识符与pid绝对值相等的所有子进程。
- 如果pid等于-1,则等待任意子进程。wait(&status)的调用与waitpid(-1, &status, 0)等价。
参数options是一个位掩码(bit mask),可以包含(按位或操作)0个或多个如下标志(均在SUSv3中加以规范)。
WUNTRACED
除了返回终止子进程的信息外,还返回因信号而停止的子进程信息。
WCONTINUED (自Linux2.6.10以来)
返回那些因收到SIGCONT信号而恢复执行的已停止子进程的状态信息。
WNOHANG
如果参数pid所指定的子进程并未发生状态改变,则立即返回,而不会阻塞,亦即poll(轮询)。在这种情况下,waitpid()返回0。如果调用进程并无与pid匹配的子进程,则waitpid()报错,将错误号置为ECHILD。
等待状态值
由wait()和waitpid()返回的status的值,可用来区分以下子进程事件。
- 子进程调用_exit()(或exit())而终止,并指定一个整型值作为退出状态。
- 子进程收到未处理信号而终止。
- 子进程因为信号而停止,并以WUNTRACED标志调用waitpid()。
- 子进程因收到信号SIGCONT而恢复,并以WCONTINUED标志调用waitpid()。
此处用术语“等待状态”(wait status)来涵盖上述所有情况,而使用“终止状态”(termination status)的称谓来指代前两种情况。(在shell中,可通过读取$?变量值来获取上次执行命令的终止状态。)
从信号处理程序中终止进程
默认情况下某些信号会终止进程。有时,可能希望在进程终止之前执行一些清理步骤。为此,可以设置一个处理程序(handler)来捕获这些信号,随即执行清理步骤,再终止进程。如果这么做,需要牢记的是:通过wait()和waitpid()调用,父进程依然可以获取子进程的终止状态。例如,如果在信号处理程序中调用_exit(EXIT_SUCCESS),父进程会认为子进程是正常终止。
如果需要通知父进程自己因某个信号而终止,那么子进程的信号处理程序应首先将自己废除,然后再次发出相同信号,该信号这次将终止这一子进程。信号处理程序需包含如下代码:
void handler(int sig)
{
signal(sig,SIG_DFL);
raise(sig);
}
系统调用waitid()
与 waitpid()类似,waitid()返回子进程的状态。不过,waitid()提供了 waitpid()所没有的扩展功能。该系统调用源于系统V(System V),不过现在已获SUSv3采用,并从版本2.6.9开始,将其加入Linux内核。
#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
参数idtype和id指定需要等待哪些子进程,如下所示。
- 如果idtype为P_ALL,则等待任何子进程,同时忽略id值。
- 如果idtype为P_PID,则等待进程ID为id进程的子进程。
- 如果idtype为P_PGID,则等待进程组ID为id各进程的所有子进程。
waitpid()与 waitid()最显著的区别在于,对于应该等待的子进程事件,waitid()可以更为精确地控制。可通过在options中指定一个或多个如下标识(按位或运算)来实现这种控制。
WEXITED
等待已终止的子进程,而无论其是否正常返回。
WSTOPPED
等待已通过信号而停止的子进程。
WCONTINUED
等待经由信号SIGCONT而恢复的子进程。
以下附加标识也可以通过按位或运算加入options中。
WNOHANG
与其在waitpid()中的意义相同。如果匹配id值的子进程中并无状态信息需要返回,则立即返回(一个轮询)。此时,waitid()返回 0。如果调用进程并无子进程与 id 的值相匹配,则waitid调用失败,且错误号为ECHILD。
WNOWAIT
通常,一旦通过waitid()来等待子进程,那么必然会去处理所谓“状态事件”。不过,如果指定了WNOWAIT,则会返回子进程状态,但子进程依然处于可等待的(waitable)状态,稍后可再次等待并获取相同信息。
执行成功,waitid()返回0,且会更新指针infop所指向的siginfo_t结构,以包含子进程的相关信息。以下是结构siginfo_t的字段情况。
si_code
该字段包含以下值之一:CLD_EXITED,表示子进程已通过调用_exit()而终止;CLD_KILLED,表示子进程为某个信号所杀;CLD_STOPPED,表示子进程因某个信号而停止;CLD_CONTINUED,表示(之前停止的)子进程因接收到(SIGCONT)信号而恢复执行。
si_pid
该字段包含状态发生变化子进程的进程ID。
si_signo
总是将该字段置为SIGCHLD。
si_status
该字段要么包含传递给_exit()的子进程退出状态,要么包含导致子进程停止、继续或终止的信号值。可以通过读取si_code值来判定具体包含的是哪一种类型的信息。
si_uid
该字段包含子进程的真正用户ID。大部分其他UNIX实现不会设置该字段。
waitid()操作的一处细节需要进一步澄清。如果在options中指定了WNOHANG,那么waitid()返回0意味着以下两种情况之一:在调用时子进程的状态已经改变(关于子进程的相关信息保存在infop指针所指向的结构 siginfo_t中),或者没有任何子进程的状态有所改变。对于没有任何子进程改变状态的情况,一些UNIX实现(包括Linux)会将siginfo_t结构内容清0。这也是区分两种情况的方法之一:检查si_pid的值是否为0。不幸的是,SUSv3并未规范这一行为,一些UNIX实现此时会保持结构siginfo_t原封不动。(未来针对SUSv4的勘误表可能会增加在这种情况下将si_pid和si_signo置0的要求。)区分这两种情况唯一可移植的方法是:在调用waitid()之前就将结构siginfo_t的内容置为0.
系统调用wait3()和wait4()
TODO.
孤儿进程与僵尸进程
父进程与子进程的生命周期一般都不相同,父、子进程间互有长短。这就引出了下面两个问题。
- 谁会是孤儿(orphan)子进程的父进程?进程ID为1的众进程之祖——init会接管孤儿进程。换言之,某一子进程的父进程终止后,对 getppid()的调用将返回 1。这是判定某一子进程之“生父”是否“在世”的方法之一(前提是假设该子进程由init之外的进程创建)。
- 在父进程执行 wait()之前,其子进程就已经终止,这将会发生什么?此处的要点在于,即使子进程已经结束,系统仍然允许其父进程在之后的某一时刻去执行 wait(),以确定该子进程是如何终止的。内核通过将子进程转为僵尸进程(zombie)来处理这种情况。这也意味着将释放子进程所把持的大部分资源,以便供其他进程重新使用。该进程所唯一保留的是内核进程表中的一条记录,其中包含了子进程ID、终止状态、资源使用数据等信息。
至于僵尸进程名称的由来,则源于 UNIX 系统对电影情节的效仿——无法通过信号来杀死僵尸进程,即便是(银弹)SIGKILL。这就确保了父进程总是可以执行wait()方法。
当父进程执行 wait()后,由于不再需要子进程所剩余的最后信息,故而内核将删除僵尸进程。另一方面,如果父进程未执行wait()随即退出,那么init进程将接管子进程并自动调用wait(),从而从系统中移除僵尸进程。
如果父进程创建了某一子进程,但并未执行 wait(),那么在内核的进程表中将为该子进程永久保留一条记录。如果存在大量此类僵尸进程,它们势必将填满内核进程表,从而阻碍新进程的创建。既然无法用信号杀死僵尸进程,那么从系统中将其移除的唯一方法就是杀掉它们的父进程(或等待其父进程终止),此时init进程将接管和等待这些僵尸进程,从而从系统中将它们清理掉。
在设计长生命周期的父进程(例如:会创建众多子进程的网络服务器和Shell)时,这些语义具有重要意义。换句话说,在此类应用中,父进程应执行 wait()方法,以确保系统总是能够清理那些死去的子进程,避免使其成为长寿僵尸。父进程在处理SIGCHLD信号时,对wait()的调用既可同步,也可异步。
SIGCHLD信号
子进程的终止属异步事件。父进程无法预知其子进程何时终止。(即使父进程向子进程发送SIGKILL信号,子进程终止的确切时间还依赖于系统的调度:子进程下一次在何时使用CPU。)之前已经论及,父进程应使用wait()(或类似调用)来防止僵尸子进程的累积,以及采用如下两种方法来避免这一问题。
父进程调用不带WNOHANG标志的wait(),或waitpid()方法,此时如果尚无已经终止的子进程,那么调用将会阻塞。
- 父进程周期性地调用带有WNOHANG标志的waitpid(),执行针对已终止子进程的非阻塞式检查(轮询)。
- 这两种方法使用起来都有所不便。一方面,可能并不希望父进程以阻塞的方式来等待子进程的终止。另一方面,反复调用非阻塞的waitpid()会造成CPU资源的浪费,并增加应用程序设计的复杂度。为了规避这些问题,可以采用针对SIGCHLD信号的处理程序。
为SIGCHLD建立信号处理程序
无论一个子进程于何时终止,系统都会向其父进程发送SIGCHLD信号。对该信号的默认处理是将其忽略,不过也可以安装信号处理程序来捕获它。在处理程序中,可以使用 wait()(或类似方法)来收拾僵尸进程。不过,使用这一方法时需要掌握一些窍门。
当调用信号处理程序时,会暂时将引发调用的信号阻塞起来(除非为 sigaction()指定了 SA_NODEFER标志),且不会对 SIGCHLD 之流的标准信号进行排队处理。这样一来,当SIGCHILD信号处理程序正在为一个终止的子进程运行时,如果相继有两个子进程终止,即使产生了两次 SIGCHLD 信号,父进程也只能捕获到一个。结果是,如果父进程的SIGCHLD信号处理程序每次只调用一次wait(),那么一些僵尸子进程可能会成为“漏网之鱼”。
解决方案是:在SIGCHLD处理程序内部循环以WNOHANG标志来调用waitpid(),直至再无其他终止的子进程需要处理为止。通常SIGCHLD处理程序都简单地由以下代码组成,仅仅捕获已终止子进程而不关心其退出状态。
while(waitpid(-1,NULL,WNOHANG)>0)
continue;
上述循环会一直持续下去,直至waitpid()返回0,表明再无僵尸子进程存在,或-1,表示有错误发生(可能是ECHILD,意即再无更多的子进程)。
SIGCHLD处理程序的设计问题
假设创建SIGCHLD处理程序的时候,该进程已经有子进程终止。那么内核会立即为父进程产生SIGCHLD信号吗?SUSv3对这一点并未规定。一些源自系统V(System V)的实现在这种情况下会产生SIGCHLD信号;而另一些系统,包括Linux,则不这么做。为保障可移植性,应用应在创建任何子进程之前就设置好SIGCHLD处理程序,将这一隐患消解于无形。(无疑,这也是顺其自然的处事之道。)
向已停止的子进程发送SIGCHLD信号
正如可以使用 waitpid()来监测已停止的子进程一样,当信号导致子进程停止时,父进程也就有可能收到 SIGCHLD 信号。调用 sigaction()设置 SIGCHLD 信号处理程序时,如传入 SA_NOCLDSTOP 标志即可控制这一行为。若未使用该标志,系统会在子进程停止时向父进程发送 SIGCHLD 信号;反之,如果使用了这一标志,那么就不会因子进程的停止而发出SIGCHLD信号。
忽略终止的子进程
更有可能像这样处理终止子进程:将对SIGCHLD的处置(disposition)显式置为SIG_ IGN,系统从而会将其后终止的子进程立即删除,毋庸转为僵尸进程。这时,会将子进程的状态弃之不问,故而所有后续的wait()(或类似)调用不会返回子进程的任何信息。
TODO.
程序的执行
执行新程序:execve()
系统调用execve()可以将新程序加载到某一进程的内存空间。在这一操作过程中,将丢弃旧有程序,而进程的栈、数据以及堆段会被新程序的相应部件所替换。在执行了各种C语言函数库的运行时启动代码以及程序的初始化代码后,例如,C++静态构造函数,或者以gcc constructor属性声明的C语言函数,新程序会从main()函数处开始执行。
由fork()生成的子进程对 execve()的调用最为频繁,不以fork()调用为先导而单独调用execve()的做法在应用中实属罕见。
基于系统调用execve(),还提供了一系列冠以exec来命名的上层库函数,虽然接口方式各异,但功能相同。通常将调用这些函数加载一个新程序的过程称作exec操作,或是简单地以exec()来表示。下面将先描述execve(),然后再对相关库函数进行说明。
#include<unistd.h>
int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
参数pathname包含准备载入当前进程空间的新程序的路径名,既可以是绝对路径(冠之以/),也可以是相对于调用进程当前工作目录(current working directory)的相对路径。
参数argv则指定了传递给新进程的命令行参数。该数组对应于C语言main()函数的第2个参数(argv),且格式也与之相同:是由字符串指针所组成的列表,以NULL结束。argv[0]的值则对应于命令名。通常情况下,该值与pathname中的basename(路径名的最后部分)相同。
最后一个参数envp指定了新程序的环境列表。参数envp对应于新程序的environ数组:也是由字符串指针组成的列表,以NULL结束,所指向的字符串格式为name=value。
调用execve()之后,因为同一进程依然存在,所以进程ID仍保持不变。还有少量其他的进程属性也未发生变化。
如果对pathname所指定的程序文件设置了set-user-ID(set-group-ID)权限位,那么系统调用会在执行此文件时将进程的有效(effective)用户(组)ID置为程序文件的属主(组)ID。利用这一机制,可令用户在运行特定程序时临时获取特权。
无论是否更改了有效ID,也不管这一变化是否生效,execve()都会以进程的有效用户ID去覆盖已保存的(saved)set-user-ID,以进程的有效组ID去覆盖已保存的(saved)set-group-ID。
由于是将调用程序取而代之,对execve()的成功调用将永不返回,而且也无需检查execve()的返回值,因为该值总是雷打不动地等于-1。实际上,一旦函数返回,就表明发生了错误。通常,可以通过errno来判断出错原因。可能自errno返回的错误如下:
EACCES
参数pathname没有指向一个常规(regular)文件,未对该文件赋予可执行权限,或者因为pathname中某一级目录不可搜索(not searchable)(即,关闭了该目录的可执行权限)。还有一种可能,是以MS_NOEXEC标志(14.8.1节)来挂载(mount)文件所在的文件系统,从而导致这一错误。
ENOENT
pathname所指代的文件并不存在。
ENOEXEC
尽管对pathname所指代文件赋予了可执行权限,但系统却无法识别其文件格式。一个脚本文件,如果没有包含用于指定脚本解释器(interpreter)(以字符#!开头)的起始行,就可能导致这一错误。
ETXTBSY
存在一个或多个进程已经以写入方式打开pathname所指代的文件。
E2BIG
参数列表和环境列表所需空间总和超出了允许的最大值。
当上述任一条件作用于执行脚本的脚本解释器,或是执行程序的ELF解释器时,同样会产生相应错误。
例1:
#include<unistd.h>
main()
{
char * argv[ ]={"ls","-al","/etc/passwd",(char *)0};
char * envp[ ]={"PATH=/bin",0};
execve("/bin/ls",argv,envp);
}
-rw-r--r-- 1 root root 2416 Aug 10 2018 /etc/passwd
该程序首先为新程序创建参数列表和环境列表,接着调用execve()来执行由命令行参数(argv[1])所指定的程序路径名。
这个结果和在终端执行ls -al /etc/passwd
的效果是一样的。
exec()库函数
本节所讨论的库函数为执行 exec()提供了多种 API 选择。所有这些函数均构建于 execve()调用之上,只是在为新程序指定程序名、参数列表以及环境变量的方式上有所不同。
#include <unistd.h>
int execle(const char *path, const char *arg,...
/*,(char *)NULL,char *const envp[] */);
int execlp(const char *file, const char *arg, ...
/*,(char *)NULL */);
int execvp(const char *file, char *const argv[]);
int execv(const char *path, char *const argv[]);
int execl(const char *path, const char *arg, ...
/*,(char *)NULL */);
函 数 | 对程序文件的描述(-, p) | 对参数的描述(v, l) | 环境变量来源(e, -) |
---|---|---|---|
execve() | 路径名 | 数组 | envp 参数 |
execle() | 路径名 | 列表 | envp 参数 |
execlp() | 文件名+PATH | 列表 | 调用者的environ |
execvp() | 文件名+PATH | 数组 | 调用者的environ |
execv() | 路径名 | 数组 | 调用者的environ |
excel() | 路径名 | 列表 | 调用者的environ |
环境变量PATH
函数execvp()和execlp()允许调用者只提供欲执行程序的文件名。二者均使用环境变量PATH来搜索文件。PATH的值是一个以冒号(:)分隔,由多个目录名,也将其称为路径前缀(path prefixes)组成的字符串。下例中的PATH包含5个目录:
$ echo PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/games:/snap/bin
对于一个登录shell而言,其PATH值将由系统级和特定用户的shell启动脚本来设置。由于子进程继承其父进程的环境变量,shell执行每个命令时所创建的进程也就继承了shell的PATH。
如果没有定义变量PATH,那么execvp()和execlp()会采用默认的路径列表:.:/usr/bin:/bin。
将程序参数指定为列表
如果在编程时已知某个exec()的参数个数,调用execle()、execlp()或者execl()时就可以将参数作为列表传入。较之于将参数装配于一个argv向量中,代码要少一些,便于使用。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("entering main process---\n");
execl("/bin/ls","ls","-l",NULL);
printf("exiting main process ----\n");
return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("entering main process---\n");
int ret;
char *argv[] = {"ls","-l",NULL};
ret = execvp("ls",argv);
if(ret == -1)
perror("execl error");
printf("exiting main process ----\n");
return 0;
}
将调用者的环境传递给新程序
函数execlp()、execvp()、execl()和execv()不允许开发者显式指定环境列表,新程序的环境继承自调用进程。
执行由文件描述符指代的程序:fexecve()
glibc自版本2.3.2开始提供函数fexecve(),其行为与execve()类似,只是指定将要执行的程序是以打开文件描述符fd的方式,而非通过路径名。有些应用程序需要打开某个程序文件,通过执行校验和(checksum)来验证文件内容,然后再运行该程序,这一场景就较为适宜使用函数fexecve()。
#define _GNU_SOURCE
#include<unistd.h>
int fexecve(int fd,char *const argv[],char *const envp[]);
当然,即便没有fexecve()函数,也可以调用open()来打开文件,读取并验证其内容,并最终运行。然而,在打开与执行文件之间,存在将该文件替换的可能性(持有打开文件描述符并不能阻止创建同名新文件),最终造成验证者并非执行者的情况。
解释器脚本
所谓解释器(interpreter),就是能够读取并执行文本格式命令的程序。(相形之下,编译器则是将输入源代码译为可在真实或虚拟机器上执行的机器语言。)各种 UNIX shell,以及诸如awk、sed、perl、python和 ruby 之类的程序都属于解释器。除了能够交互式地读取和执行命令之外,解释器通常还具备这样一种能力:从被称为脚本(script)的文本文件中读取和执行命令。
UNIX 内核运行解释器脚本的方式与二进制(binary)程序无异,前提是脚本必须满足下面两点要求:首先,必须赋予脚本文件可执行权限;其次,文件的起始行(initial line)必须指定运行脚本解释器的路径名。
文件描述符与exec()
默认情况下,由exec()的调用程序所打开的所有文件描述符在exec()的执行过程中会保持打开状态,且在新程序中依然有效。这通常很实用,因为调用程序可能会以特定的描述符来打开文件,而在新程序中这些文件将自动有效,无需再去了解文件名或是把它们重新打开。
shell利用这一特性为其所执行的程序处理I/O重定向。例如,假设键入如下的shell命令:
ls /tmp > dir.txt
shell运行该命令时,执行了以下步骤。
1. 调用fork()创建子进程,子进程会也运行shell的一份拷贝(因此命令行也有一份拷贝)。
2. 子shell以描述符1(标准输出)打开文件dir.txt用于输出。
3. 子shell执行程序ls。ls将其结果输出到标准输出,亦即文件dir.txt中。
此处对shell处理I/O重定向的解释有所简化。特别是,某些命令,即所谓shell内建命令,是由shell直接运行的,并未调用fork()或者exec()。在处理I/O重定向时,针对这样的命令必须进行特殊处理。
将某一 shell 命令实现为内建命令,不外乎如下两个目的:效率以及会对 shell 产生副作用(side effect)。一些频繁使用的命令(如pwd、echo和test)逻辑都很简单,放在shell内部实现效率会更高。将其他命令内置于 shell实现,则是希望命令对shell本身能产生副作用:更改shell所存储的信息,修改shell进程的属性,亦或是影响shell进程的运行。例如,cd命令必须改变shell自身的工作目录,故而不应在一个独立进程中执行。产生副作用的内建命令还包括exec、exit、read、set、source、ulimit、umask、wait以及shell的作业控制(job-control)命令(jobs、fg和bg)。想了解shell支持的全套内建命令,可参考shell手册页(manual page)文档。
执行时关闭(close-on-exec)标志(FD_CLOEXEC)
在执行 exec()之前,程序有时需要确保关闭某些特定的文件描述符。尤其是在特权进程中调用exec()来启动一个未知程序时(并非自己编写),亦或是启动程序并不需要使用这些已打开的文件描述符时,从安全编程的角度出发,应当在加载新程序之前确保关闭那些不必要的文件描述符。对所有此类描述符施以close()调用就可达到这一目的,然而这一做法存在如下局限性。
- 某些描述符可能是由库函数打开的。但库函数无法使主程序在执行exec()之前关闭相应的文件描述符。作为基本原则,库函数应总是为其打开的文件设置执行时关闭(close-on-exec)标志,稍后将介绍所使用的技术。
- 如果exec()因某种原因而调用失败,可能还需要使描述符保持打开状态。如果这些描述符已然关闭,将它们重新打开并指向相同文件的难度很大,基本上不太可能。
为此,内核为每个文件描述符提供了执行时关闭标志。如果设置了这一标志,那么在成功执行exec()时,会自动关闭该文件描述符,如果调用exec()失败,文件描述符则会保持打开状态。可以通过系统调用fcntl()来访问执行时关闭标志。fcntl()的F_GETFD操作可以获取文件描述符标志的一份拷贝:
int flags;
flags=fcntl(fd,F_GETFD);
if(flags==-1)
printf("fcntl");
获取这些标志后,可以对FD_CLOEXEC位进行修改,再调用fcntl()的F_SETFD操作令其生效:
flags |= FD_CLOEXEC;
if(fcntl(fd,F_SETFD,flags)==-1)
printf("fcntl");
当使用 dup()、dup2()或fcntl()为一文件描述符创建副本时,总是会清除副本描述符的执行时关闭标志。(这一现象既有其历史渊源,也顺应了 SUSv3 的要求。)
信号与exec()
exec()在执行时会将现有进程的文本段丢弃。该文本段可能包含了由调用进程创建的信号处理器程序。既然处理器已经不知所踪,内核就会将对所有已设信号的处置重置为SIG_DFL。而对所有其他信号(即将处置置为SIG_IGN或SIG_DFL的信号)的处置则保持不变。这也符合SUSv3的要求。
不过,遭忽略的 SIGCHLD 信号属于 SUSv3 中的特例。(之前曾在 26.3.3 节提及,忽略SIGCHLD能够阻止僵尸进程的产生)。至于调用 exec()之后,是继续让遭忽略的 SIGCHLD 信号保持被忽略状态,还是将对其处置重置为 SIG_DFL,SUSv3 对此不置可否。Linux 的操作取其前者,而其他一些UNIX实现(如:Solaris)则采用后者。这就意味着,对于忽略SIGCHLD 的程序而言,要最大限度的保证可移植性,就应该在调用 exec()之前执行 signal(SIGCHLD,SIG_DFL)。此外,程序也不应当假设对SIGCHLD处置的初始设置是SIG_DFL之外的其他值。
老程序的数据段、堆以及栈悉数被毁,这也意味着通过sigaltstack()(21.3节)所创建的任何备选信号栈都会丢失。由于 exec()在调用期间不会保护备选信号栈,故而也会将所有信号的SA_ONSTACK位清除掉。
在调用 exec()期间,进程信号掩码以及挂起(pending)信号的设置均得以保存。这一特性允许对新程序的信号进行阻塞和排队处理。不过,SUSv3 指出,许多现有应用程序的编写都基于如下的错误假设:程序启动时将对某些特定信号的处置置为 SIG_DFL,又或者并未阻塞这些信号。(特别是,C语言标准对信号的规范很弱,对信号阻塞也未置一词,所以为非 UNIX系统所编写的C程序也不可能去解除对信号的阻塞。)为此,SUSv3建议,在调用 exec()执行任何程序的过程中,不应当阻塞或忽略信号。这里的“任何程序”是指并非由 exec()的调用者所编写的程序。至于说如果执行和被执行的程序均出自一人之手,又或者对运行程序处理信号的手法知根知底,那自然又另当别论。
执行shell命令:system()
程序可通过调用system()函数来执行任意的shell命令。
#include<stdlib.h>
int system(const char *command);
函数system()创建一个子进程来运行shell,并以之执行命令command。
system()的主要优点在于简便。
- 无需处理对fork()、exec()、wait()和exit()的调用细节。
- system()会代为处理错误和信号。
- 因为system()使用shell来执行命令(command),所以会在执行command之前对其进行所有的常规shell处理、替换以及重定向操作。为应用增加“执行一条shell命令”的功能不过是举手之劳。(许多交互式应用程序以“!command”的形式提供了这一功能。)
但这些优点是以低效率为代价的。使用system()运行命令需要创建至少两个进程。一个用于运行 shell,另外一个或多个则用于 shell 所执行的命令(执行每个命令都会调用一次exec())。如果对效率或者速度有所要求,最好还是直接调用fork()和exec()来执行既定程序。
system()的返回值如下。
- 当command为NULL指针时,如果shell可用则system()返回非0值,若不可用则返回0。这种返回值方式源于C语言标准,因为并未与任何操作系统绑定,所以如果system()运行在非 UNIX 系统上,那么该系统可能是没有shell的。此外,即便所有UNIX实现都有 shell,如果程序在调用system()之前又调用了chroot(),那么shell依然可能无效。若command不为NULL,则system()的返回值由本列表中的余下规则决定。
- 如果无法创建子进程或是无法获取其终止状态,那么system()返回-1。
- 若子进程不能执行shell,则system()的返回值会与子shell调用_exit(127)终止时一样。
- 如果所有的系统调用都成功,system()会返回执行command的子shell的终止状态。shell 的终止状态是其执行最后一条命令时的退出状态;如果命令为信号所杀,大多数shell 会以值 128+n 退出,其中 n 为信号编号.
应用需要加载其他程序时,为确保安全过关,应当直接调用fork()和exec()系函数(execlp()和execvp()除外)之一。
详述进程创建和程序执行
进程记账
打开进程记账功能后,内核会在每个进程终止时将一条记账信息写入系统级的进程记账文件。这条账单记录包含了内核为该进程所维护的多种信息,包括终止状态以及进程消耗的CPU时间。借助于标准工具(sa(8) 对账单文件进行汇总,lastcomm(1)则就先前执行的命令列出相关信息)或是定制应用,可对记账文件进行分析。
内核2.6.10之前,内核会为基于NPTL线程实现所创建的每个线程单独记录一条进程记账信息。自内核2.6.10开始,只有当最后一个线程退出时才会为整个进程保存一条账单记录。至于更老的LinuxThread线程实现,则会为每个线程单独记录一条进程记账信息。
从历史上看,进程记账主要用于在多用户UNIX系统上针对用户所消耗的系统资源进行计费。不过,如果进程的信息并未由其父进程进行监控和报告,那么就可以使用进程记账来获取。
虽然大部分UNIX实现都支持进程记账功能,但SUSv3并未对其进行规范。账单记录的格式、记账文件的位置也随系统实现的不同而多少存在差别。
Linux系统的进程记账功能属于可选内核组件,可以通过CONFIGBSD_PROCESS ACCT选项进行配置。
打开和关闭进程记账功能
特权进程可利用系统调用acct()来打开和关闭进程记账功能。应用程序很少使用这一系统调用。一般会将相应命令置于系统启动脚本中,在系统每次重启时开启进程记账功能。
#define _BSD_SOURCE
#include<unistd.h>
int acct(const char *acctfile);
为了打开进程账单功能,需要在参数acctfile中指定一个现有常规文件的路径名。记账文件通常的路径名是/var/log/pacct或/usr/account/pacct。若想关闭进程记账功能,则指定acctfile为NULL即可。
程序清单中程序使用acct()来开关进程的记账功能。该程序的作用类似于shell命令accton(8)。
#define _BSD_SOURCE
#include <unistd.h>
#include <stdio.h>
#include<stdlib.h>
#include <string.h>
int
main(int argc, char *argv[])
{
if (argc > 2 || (argc > 1 && strcmp(argv[1], "--help") == 0))
printf("%s [file]\n", argv[0]);
if (acct(argv[1]) == -1)
printf("acct");
printf("Process accounting %s\n",
(argv[1] == NULL) ? "disabled" : "enabled");
exit(EXIT_SUCCESS);
}
acctProcess accounting disabled
进程账单记录
一旦打开进程记账功能,每当一进程终止时,就会有一条acct记录写入记账文件。acct结构定义于头文件<sys/acct.h>中,具体如下:
struct acct{
char ac_flag; /* Accounting flags. */
u_int16_t ac_uid; /* Accounting user ID. */
u_int16_t ac_gid; /* Accounting group ID. */
u_int16_t ac_tty; /* Controlling tty. */
u_int32_t ac_btime; /* Beginning time. */
comp_t ac_utime; /* Accounting user time. */
comp_t ac_stime; /* Accounting system time. */
comp_t ac_etime; /* Accounting elapsed time. */
comp_t ac_mem; /* Accounting average memory usage. */
comp_t ac_io; /* Accounting chars transferred. */
comp_t ac_rw; /* Accounting blocks read or written. */
comp_t ac_minflt; /* Accounting minor pagefaults. */
comp_t ac_majflt; /* Accounting major pagefaults. */
comp_t ac_swaps; /* Accounting number of swaps. */
u_int32_t ac_exitcode; /* Accounting process exitcode. */
char ac_comm[ACCT_COMM+1]; /* Accounting command name. */
char ac_pad[10]; /* Accounting padding bytes. */
};
系统调用clone()
类似于fork()和vfork(),Linux特有的系统调用clone()也能创建一个新进程。与前两者不同的是,后者在进程创建期间对步骤的控制更为精准。clone()主要用于线程库的实现。由于clone()有损于程序的可移植性,故而应避免在应用程序中直接使用。
#define _GNU_SOURCE
#include<sched.h>
int clone(int (*func) (void *),void *child_slack,int flags,void *func_arg,...
/* pid_t *ptid, struct user_desc *tls,pid_t *ctid*/);
如同fork(),由clone()创建的新进程几近于父进程的翻版。
但与fork()不同的是,克隆生成的子进程继续运行时不以调用处为起点,转而去调用以参数func所指定的函数,func又称为子函数(child function)。调用子函数时的参数由 func_arg指定。经过适当转换,子函数可对该参数的含义自由解读,例如,可以作为整型值(int),也可视为指向结构的指针。(之所以可以作为指针处理,是因为克隆产生的子进程对调用进程的内存既可获取,也可共享。)
对于内核而言,fork()、vfork()以及clone()最终均由同一函数实现(kernel/fork.c中的do_fork())。在这一层次上,clone与fork更为接近:sys_clone()并没有func和func_arg参数,且调用后sys_clone()在子进程中返回的方式也与fork()相同。正文所述的clone()是由glibc为sys_clone()提供的封装函数。
当函数func返回(此时其返回值即为进程的退出状态)或是调用exit()(或_exit())之后,克隆产生的子进程就会终止。照例,父进程可以通过wait()一类函数来等待克隆子进程。
因为克隆产生的子进程可能(类似vfork())共享父进程的内存,所以它不能使用父进程的栈。相反,调用者必须分配一块大小适中的内存空间供子进程的栈使用,同时将这块内存的指针置于参数child_stack中。在大多数硬件架构中,栈空间的增长方向是向下的,所以参数child_stack应当指向所分配内存块的高端。
栈增长方向对架构的依赖是clone()设计的一处缺陷。Interl IA-64架构就提供了一款经过改善的克隆API,称为clone2()。该系统调用对子进程栈范围的定义方式不依赖于栈的增长方向,只需要提供栈的起始地址以及大小即可。
函数clone()的参数flags服务于双重目的。首先,其低字节中存放着子进程的终止信号(terminateion signal),子进程退出时其父进程将收到这一信号。(如果克隆产生的子进程因信号而终止,父进程依然会收到SIGCHLD信号。)该字节也可能为0,这时将不会产生任何信号。(借助于Linux特有的/proc/PID/stat文件,可以判定任何进程的终止信号,详情请参阅proc(5)手册页。)
对于fork()和vfork()而言,就无从选择终止信号,只能是SIGCHLD。
标 志 | 设置后的效果 |
---|---|
CLONE_CHILD_CLEARTID | 当子进程调用exec()或_exit()时,清除ctid(从版本2.6开始) |
CLONE_CHILD_SETTID | 将子进程的线程ID写入ctid(从2.6版本开始) |
CLONE_FILES | 父、子进程共享打开文件描述符表 |
CLONE_FS | 父、子进程共享与文件系统相关的属性 |
CLONE_IO | 子进程共享父进程的I/O上下文环境(从2.6.25版本开始) |
CLONE_NEWIPC | 子进程获得新的System V IPC命名空间(从2.6.19开始) |
CLONE_NEWNET | 子进程获得新的网络命名空间(从2.4.24版本开始) |
CLONE_NEWNS | 子进程获得父进程挂载(mount)命名空间的副本(从2.4.19版本开始) |
CLONE_NEWPID | 子进程获得新的进程ID命名空间(从2.6.23版本开始) |
CLONE_NEWUSER | 子进程获得新的用户ID命名空间(从2.6.23版本开始) |
CLONE_NEWUTS | 子进程获得新的UTS(utsname())命名空间(从2.6.19版本开始) |
CLONE_PARENT | 将子进程的父进程置为调用者的父进程(从2.4版本开始) |
CLONE_PARENT_SETTID | 将子进程的线程ID写入ptid(从2.6版本开始) |
CLONE_PID | 标志已废止,仅用于系统启动进程(直至2.4版本为止) |
CLONE_PTRACE | 如果正在跟踪父进程,那么子进程也照此办理 |
CLONE_SETTLS | tls描述子进程的线程本地存储(从2.6开始) |
CLONE_SIGHAND | 父、子进程共享对信号的处置设置 |
CLONE_SYSVSEM | 父、子进程共享信号量还原(undo)值(从2.6版本开始) |
CLONE_THREAD | 将子进程置于父进程所属的线程组中(从2.4开始) |
CLONE_UNTRACED | 不强制对子进程设置CLONE_PTRACE(从2.6版本开始) |
CLONE_VFORK | 挂起父进程直至子进程调用exec()或_exit() |
CLONE_VM | 父、子进程共享虚拟内存 |
clone()的余下参数分别是:ptid、tls和ctid。这些参数与线程的实现相关,尤其是在针对线程ID以及线程本地存储的使用方面。
clone()的flags参数
clone()的flags参数是各种位掩码的组合(“或”操作),下面将对它们一一说明。
共享文件描述符表:CLONE_FILES
如果指定了CLONE_FILES标志,父、子进程会共享同一个打开文件描述符表。也就是说,无论哪个进程对文件描述符的分配和释放(open()、close()、dup()、pipe()、socket()等),都会影响到另一进程。如果未设置CLONE_FILES,那么也就不会共享文件描述符表,子进程获取的是父进程调用clone()时文件描述符表的一份拷贝。这些描述符副本与其父进程中的相应描述符均指向相同的打开文件(和fork()和vfork()的情况一样)。
POSIX线程规范要求进程中的所有线程共享相同的打开文件描述符。
共享与文件系统相关的信息:CLONE_FS
如果指定了CLONE_FS标志,那么父、子进程将共享与文件系统相关的信息(file system- related information):权限掩码(umask)、根目录以及当前工作目录。也就是说,无论在哪个进程中调用umask ()、chdir()或者chroot(),都将影响到另一个进程。如果未设置CLONE_FS,那么父、子进程对此类信息则会各持一份(与fork()和vfork()的情况相同)。
POSIX线程规范要求实现CLONE_FS标志所提供的属性共享。
共享对信号的处置设置:CLONE_SIGHAND
如果设置了CLONE_SIGHAND,那么父、子进程将共享同一个信号处置表。无论在哪个进程中调用sigaction()或signal()来改变对信号处置的设置,都会影响其他进程对信号的处置。若未设置CLONE_SIGHAND,则不共享对信号的处置设置,子进程只是获取父进程信号处置表的一份副本(如同fork()和vfork())。CLONE_SIGHAND不会影响到进程的信号掩码以及对挂起(pending)信号的设置,父子进程的此类设置是绝不相同的。从Linux 2.6开始,如果设置了CLONE_SIGHAND,就必须同时设置CLONE_VM。
POSIX线程规范要求共享对信号的处置设置。
共享父进程的虚拟内存:CLONE_VM
如果设置了CLONE_VM标志,父、子进程会共享同一份虚拟内存页(如同vfork())。无论哪个进程更新了内存,或是调用了mmap()、munmap(),另一进程同样会观察到这些变化。如果未设置CLONE_VM,那么子进程得到的是对父进程虚拟内存的拷贝(如同fork())。
共享同一虚拟内存是线程的关键属性之一,POSIX线程标准对此也有要求。
线程组:CLONE_THREAD
若设置了CLONE_THREAD,则会将子进程置于父进程的线程组中。如果未设置该标志,那么会将子进程置于新的线程组中。
POSIX标准规定,进程的所有线程共享同一进程ID(即每个线程调用getpid()都应返回相同值),Linux从2.4版本开始引入了线程组(threads group),以满足这一需求。如图28-1 所示,线程组就是共享同一线程组标识(TGID)(thread group identifier)的一组KSE。在对CLONE_THREAD的后续讨论中,会将KSE视同线程看待。
始于Linux2.4,getpid()所返回的就是调用者的TGID。换言之,TGID和进程ID是一回事。
在2.2以及更早的Linux系统中,对clone()的实现并不支持CLONE_THREAD。相反,LinuxThreads曾将POSIX线程实现为共享了多种属性(例如,虚拟内存)、进程ID又各不相同的进程。考虑到兼容性因素,即便是在当前的Linux内核中,LinuxThreads实现也未提供CLONE_THREAD,因为按此方式实现的线程就可以继续拥有不同的进程ID。
一个线程组内的每个线程都拥有一个唯一的线程标识符(thread identifier,TID),用以标识自身。
Linux 2.4提供了一个新的系统调用gettid(),线程可通过该调用来获取自己的线程ID(与线程调用clone()时的返回值相同)。线程ID与进程ID都使用相同的数据类型pid_t来表示。线程ID在整个系统中是唯一的,且除了线程担当进程中线程组首线程的情况之外,内核能够保证系统中不会出现线程ID与进程ID相同的情况。
线程组中首个线程的线程ID与其线程组ID相同,也将该线程称之为线程组首线程(thread group leader)。
线程组中的所有线程拥有同一父进程ID,即与线程组首线程ID相同。仅当线程组中的所有线程都终止后,其父进程才会收到SIGCHLD信号(或其他终止信号)。这些行为符合POSIX线程规范的要求。
当一个设置了CLONE_THREAD的线程终止时,并没有信号会发送给该线程的创建者(即调用clone()创建终止线程的线程)。相应的,也不可能调用 wait()(或类似函数)来等待一个以 CLONE_THREAD标志创建的线程。这与POSIX的要求一致。POSIX线程与进程不同,不能使用wait()等待,相反,必须调用pthread_join()来加入。为检测以CLONE_THREAD标志创建的线程是否终止,需要使用一种特殊的同步原语——futex(参考下文对CLONE_PARENT_SETTID标志的讨论)。
如果一个线程组中的任一线程调用了exec(),那么除了首线程之外的其他线程都会终止(这一行为也符合 POSIX 线程规范的要求),新进程将在首线程中执行。换言之,新程序中的 gettid()调用将会返回首线程的线程ID。调用exec()期间,会将该进程发送给其父进程的终止信号重置为SIGCHLD。
如果线程组中的某个线程调用fork()或vfork()创建了子进程,那么组中的任何线程都可使用wait()或类似函数来监控该子进程。
从Linux2.6开始,如果设置了CLONE_THREAD,同时也必须设置CLONE_SIGHAND。这也与POSIX线程标准的深入要求相契合,详细内容可参考33.2节关于POSIX线程与信号交互的相关讨论。(内核针对CLONE_THREAD线程组的信号处理对应于POSIX标准对进程中线程如何处理信号的规范。)
线程库支持:CLONE_PARENT_SETTID、CLONE_CHILD_SETTID和CLONE_CHILD_CLEARTID
为实现POSIX线程,Linux 2.6提供了对CLONE_PARENT_SETTID、CLONE_CHILD_SETTID和CLONE_CHILD_CLEARTID的支持。这些标志会影响clone()对参数ptid和ctid的处理。NPTL的线程实现使用了CLONE_CHILD_SETTID和CLONE_CHILD_CLEARTID。
如果设置了CLONE_PARENT_SETTID,内核会将子线程的线程ID写入ptid所指向的位置。在对父进程的内存进行复制之前,会将线程ID复制到ptid所指位置。这也意味着,即使没有设置CLONE_VM,父、子进程均能在此位置获得子进程的线程ID。(如上所述,创建POSIX线程时总是指定了CLONE_VM标志。)
CLONE_PARENT_SETTID之所以存在,意在为线程实现获取新线程ID提供一种可靠的手段。注意,通过clone()的返回值并不足以获取新线程的线程ID。
tid=clone(...);
问题在于,因为赋值操作只能在clone()返回后才会发生,所以以上代码会导致各种竞争条件。例如,假设新线程终止,而在完成对tid的赋值前就调用了终止信号的处理器程序。此时,处理器程序无法有效访问tid。(在线程库内部,可能会将tid置于一个用以跟踪所有线程状态的全局结构中。)程序通常可以通过直接调用clone()来规避这种竞争条件。不过,线程库无法控制其调用者程序的行为。使用CLONE_PARENT_SETTID可以保证在clone()返回之前就将新线程的ID赋值给ptid指针,从而使线程库避免了这种竞争条件。
如果设置了CLONE_CHILD_SETTID,那么clone()会将子线程的线程ID写入指针ctid所指向的位置。对ctid的设置只会发生在子进程的内存中,不过如果设置了CLONE_VM,还是会影响到父进程。虽然NPTL并不需要CLONE_CHILD_SETTID,但这一标识还是能给其他的线程库实现带来灵活性。
如果设置了CLONE_CHILD_CLEARTID标志,那么clone()会在子进程终止时将ctid所指向的内存内容清零。
借助于参数ctid所提供的机制(稍后描述),NPTL线程实现可以获得线程终止的通知。函数pthread_join()正需要这样的通知,POSIX线程利用该函数来等待另一线程的终止。
使用pthread_create()创建线程时,NPTL会调用clone(),其ptid和ctid均指向同一位置。(这正是NPTL不需要CLONE_CHILD_SETTID的原因所在。)设置了CLONE_PARENT_SETTID标志,就会以新的线程ID对该位置进行初始化。当子进程终止,ctid遭清除时,进程中的所有线程都会目睹这一变化(因为设置了CLONE_VM)。
内核将ctid指向的位置视同futex——一种有效的同步机制来处理。(关于futex的更多内容请参考futex(2)手册页。)执行系统调用futex()来监测ctid所指位置的内容变化,就可获得线程终止的通知。(这正是pthread_join()所做的幕后工作。)内核在清除ctid的同时,也会唤醒那些调用了futex()来监控该地址内容变化的任一内核调度实体(即线程)。(在POSIX线程的层面上,这会导致pthread_join()调用去解除阻塞。)
线程本地存储:CLONE_SETTLS
如果设置了CLONE_SETTLS,那么参数tls所指向的user_desc结构会对该线程所使用的线程本地存储缓冲区加以描述。为了支持NPTL对线程本地存储的实现,Linux 2.6开始加入这一标志(31.4节)。关于user_desc结构的详情,可参考2.6内核代码中对该结构的定义和使用,以及set_thread_area(2)手册页。
共享System V信号量的撤销值:CLONE_SYSVSEM
如果设置了CLONE_SYSVSEM,父、子进程将共享同一个System V信号量撤销值列表(47.8节)。如果未设置该标志,父、子进程各自持有取消列表,且子进程的列表初始为空。
每进程挂载命名空间:CLONE_NEWNS
Linux从内核2.4.19开始支持每进程挂载(mount)命名空间的概念。挂载命名空间是由对mount()和umount()的调用来维护的一组挂载点。挂载命名空间会影响将路径名解析为真实文件的过程,也会波及诸如chdir()和chroot()之类的系统调用。
默认情况下,父、子进程共享同一挂载命名空间,一个进程调用mount()或umount()对命名空间所做的改变,也会为其他进程所见(如同fork()和vfork())。特权级(CAP_SYS_ADMIN)进程可以指定CONE_NEWNS标志,以便子进程去获取对父进程挂载命名空间的一份拷贝。这样一来,进程对命名空间的修改就不会为其他进程所见。(早期的2.4.x内核以及更老的版本认为,系统的所有进程共享同一个系统级挂载命名空间。)
可以利用每进程挂载命名空间来创建类似于chroot()监禁区(jail)的环境,而且更加安全、灵活,例如,可以向遭到监禁的进程提供一个挂载点,而该点对于其他进程是不可见的。设置虚拟服务器环境时也会用到挂载命名空间。
在同一clone()调用中同时指定CLONE_NEWNS和CLONE_FS纯属无聊,也不允许这样做。
将子进程的父进程置为调用者的父进程:CLONE_PARENT
默认情况下,当调用clone()创建新进程时,新进程的父进程(由getppid()返回)就是调用clone()的进程(同fork()和vfork())。如果设置了CLONE_PARENT,那么调用者的父进程就成为子进程的父进程。换言之,CLONE_PARENT等同于这样的设置:子进程.PPID = 调用者.PPID。(未设置CLONE_PARENT的默认情况是:子进程.PPID = 调用者.PID。)子进程终止时会向父进程(子进程.PPID)发出信号。
Linux从版本2.4之后开始支持CLONE_PARENT。其设计初衷意图是对POSIX线程的实现提供支持,不过内核 2.6 找出一种无需此标志而支持线程(之前所述的 CLONE_THREAD)的新方法。
将子进程的进程ID置为与父进程相同:CLONE_PID(已废止)
如果设置了CLONE_PID,那么子进程就拥有与父进程相同的进程ID。若未设置此标志,那么父、子进程的进程ID则不同(如同fork()和vfork())。只有系统引导进程(进程ID为0)可能会使用该标志,用于初始化多处理器系统。
CLONE_PID的设计初衷并非供用户级应用使用。Linux 2.6已将其移除,并以CLONE_IDLETASK取而代之,将新进程的ID置为0。CLONE_IDLETASK仅供内核内部使用(即使在clone()的参数中指定,系统也会对其视而不见)。使用此标志可为每颗CPU创建隐身的空闲进程(idle process),在多处理器系统中可能存在有多个实例。
进程跟踪:CLONE_PTRACE和CLONE_UNTRACED
如果设置了CLONE_PTRACE且正在跟踪调用进程,那么也会对子进程进行跟踪。关于进程跟踪(由调试器和strace命令使用)的细节,请参考ptrace(2)手册页。
从内核2.6开始,即可设置CLONE_UNTRACED标志,这也意味着跟踪进程不能强制将其子进程设置为CLONE_PTRACE。CLONE_UNTRACED标志供内核创建内核线程时内部使用。
挂起(suspending)父进程直至子进程退出或调用exec():CLONE_VFORK
如果设置了CLONE_VFORK,父进程将一直挂起,直至子进程调用exec()或_exit()来释放虚拟内存资源(如同vfork())为止。
支持容器(container)的clone()新标志
Linux从2.6.19版本开始给clone()加入了一些新标志:CLONE_IO、CLONE_NEWIPC、CLONET_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和CLONE_NEWUTS。(参考clone(2)手册页可获得有关这些标志的详细说明。)
这些标志中的大部分都是为容器(container)的实现提供支持([Bhattiprolu et al., 2008])。容器是轻量级虚拟化的一种形式,将运行于同一内核的进程组从环境上彼此隔离,如同运行在不同机器上一样。容器可以嵌套,一个容器可以包含另一个容器。与完全虚拟化将每个虚拟环境运行于不同内核的手法相比,容器的运作方式可谓是大相径庭。
为实现容器,内核开发者不得不为内核中的各种全局系统资源提供一个间接层,以便每个容器能为这些资源提供各自的实例。这些资源包括:进程ID、网络协议栈、uname()返回的ID、System V IPC对象、用户和组ID命名空间……
容器的用途很多,如下所示。
- 控制系统的资源分配,诸如网络带宽或CPU时间(例如,授予容器某甲75%的CPU时间,某乙则获取25%)。
- 在单台主机上提供多个轻量级虚拟服务器。
- 冻结某个容器,以此来挂起该容器中所有进程的执行,并于稍后重启,可能是在迁移到另一台机器之后。
- 允许转储应用程序的状态信息,记录于检查点(checkpointed),并于之后再行恢复(或许在应用程序崩溃之后,亦或是计划内、外的系统停机后),从检查点开始继续运行。
clone()标志的使用
大体上说来,fork()相当于仅设置flags为SIGCHLD的clone()调用,而vfork()则对应于设置如下flags的clone():
CLPNE_VM | CLONE_VFORK | SIGCHILD
LinuxThreads线程实现使用clone()(仅用到前4个参数)来创建线程,对flags的设置如下:
CLPNE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND
NPTL线程实现则使用clone()(使用了所有7个参数)来创建线程,对flags的设置如下:
CLPNE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | CLONE_SYSVSEM
因克隆生成的子进程而对waitpid()进行的扩展
为等待由clone()产生的子进程,waitpid()、wait3()和wait4()的位掩码参数options可以包含如下附加(Linux特有)值。
__WCLONE
一经设置,只会等待克隆子进程。如未设置,只会等待非克隆子进程。在这种情况下,克隆子进程终止时发送给其父进程的信号并非SIGCHLD。如果同时还指定了__WALL,那么将忽略__WCLONE。
__WALL(自Linux2.4之后)
等待所有子进程,无论类型(克隆、非克隆通吃)。
__WNOTHREAD(自Linux2.4之后)
默认情况下,等待(wait)类调用所等待的子进程,其父进程的范围遍及与调用者隶属同一线程组的任何进程。指定__WNOTHREAD标志则限制调用者只能等待自己的子进程。
waitid()不能使用上述标志。
进程的创建速度
exec()和fork()对进程属性的影响
TODO.
线程:介绍
启动程序时,产生的进程只有单条线程,称之为初始(initial)或主(main)线程。
概述
与进程(process)类似,线程(thread)是允许应用程序并发执行多个任务的一种机制。一个进程可以包含多个线程。同一程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括初始化数据段(initialized data)、未初始化数据段(uninitialized data),以及堆内存段(heap segment)。(传统意义上的UNIX进程只是多线程程序的一个特例,该进程只包含一个线程。)
同一进程中的多个线程可以并发执行。在多处理器环境下,多个线程可以同时并行。如果一线程因等待I/O操作而遭阻塞,那么其他线程依然可以继续运行。
对于某些应用而言,线程要优于进程。传统UNIX通过创建多个进程来实现并行任务。以网络服务器的设计为例,服务器进程(父进程)在接受客户端的连接后,会调用 fork()来创建一个单独的子进程,以处理与客户端的通信。采用这种设计,服务器就能同时为多个客户端提供服务。虽然这种方法在很多情境下都屡试不爽,但对于某些应用来说也确实存在如下一些限制。
- 进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此必须采用一些进程间通信(inter-process communication,简称IPC)方式,在进程间进行信息交换。
- 调用 fork()来创建进程的代价相对较高。即便利用写时复制(copy-on-write)技术,仍然需要复制诸如内存页表(page table)和文件描述符表(file descriptor table)之类的多种进程属性,这意味着fork()调用在时间上的开销依然不菲。
线程解决了上述两个问题。
- 线程之间能够方便、快速地共享信息。只需将数据复制到共享(全局或堆)变量中即可。不过,要避免出现多个线程试图同时修改同一份信息的情况,这需要使用同步技术。
- 创建线程比创建进程通常要快10倍甚至更多。(在 Linux 中,是通过系统调用 clone()来实现线程的)线程的创建之所以较快,是因为调用 fork()创建子进程时所需复制的诸多属性,在线程间本来就是共享的。特别是,既无需采用写时复制来复制内存页,也无需复制页表。
除了全局内存之外,线程还共享了一干其他属性(这些属性对于进程而言是全局性的,而并非针对某个特定线程),包括以下内容。
- 进程ID(process ID)和父进程ID。
- 进程组ID与会话ID(session ID)。
- 控制终端。
- 进程凭证(process credential)(用户ID和组ID )。
- 打开的文件描述符。
- 由fcntl()创建的记录锁(record lock)。
- 信号(signal)处置。
- 文件系统的相关信息:文件权限掩码(umask)、当前工作目录和根目录。
- 间隔定时器(setitimer())和POSIX定时器(timer_create())。
- 系统V(system V)信号量撤销(undo,semadj)值(47.8节)。
- 资源限制(resource limit)。
- CPU时间消耗(由times()返回)。
- 资源消耗(由getrusage()返回)。
- nice值(由setpriority()和nice()设置)。
各线程所独有的属性,如下列出了其中一部分。
- 线程ID(thread ID,29.5节)。
- 信号掩码(signal mask)。
- 线程特有数据(31.3节)。
- 备选信号栈(sigaltstack())。
- errno变量。
- 浮点型(floating-point)环境(见fenv(3))。
- 实时调度策略(real-time scheduling policy)和优先级(35.2节和35.3节)。
- CPU亲和力(affinity,Linux所特有,35.4节将加以描述)。
- 能力(capability,Linux所特有,第39章将加以描述)。
- 栈,本地变量和函数的调用链接(linkage)信息。
Pthreads API的详细背景
20世纪80年代末、90年代初,存在着数种不同的线程接口。1995年POSIX.1c对POSIX线程API进行了标准化,该标准后来为SUSv3所接纳。
有几个概念贯穿整个Pthreads API,在深入探讨API之前,将简单予以介绍。
线程数据类型(Pthreads data type)
Pthreads API定义了一干数据类型,下表列出了其中的一部分。
数 据 类 型 | 描 述 |
---|---|
pthread_t | 线程ID |
pthread_mutex_t | 互斥对象(Mutex) |
pthread_mutexattr_t | 互斥属性对象 |
pthread_cond_t | 条件变量(condition variable) |
pthread_condattr_t | 条件变量的属性对象 |
pthread_key_t | 线程特有数据的键(Key) |
pthread_once_t | 一次性初始化控制上下文(control context) |
pthread_attr_t | 线程的属性对象 |
SUSv3并未规定如何实现这些数据类型,可移植的程序应将其视为“不透明”数据。亦即,程序应避免对此类数据类型变量的结构或内容产生任何依赖。尤其是,不能使用C语言的比较操作符(==)去比较这些类型的变量。
线程和errno
在传统UNIX API中,errno是一全局整型变量。然而,这无法满足多线程程序的需要。如果线程调用的函数通过全局errno返回错误时,会与其他发起函数调用并检查errno的线程混淆在一起。换言之,这将引发竞争条件(race condition)。因此,在多线程程序中,每个线程都有属于自己的errno。在Linux中,线程特有errno的实现方式与大多数UNIX实现相类似:将 errno 定义为一个宏,可展开为函数调用,该函数返回一个可修改的左值(lvalue),且为每个线程所独有。(因为左值可以修改,多线程程序依然能以errno=value的方式对errno赋值。)
一言以蔽之,errno机制在保留传统UNIX API报错方式的同时,也适应了多线程环境。
最初的POSIX.1标准沿袭K&R的C语言用法,允许程序将errno声明为extern int errno。SUSv3却不允许这一做法(这一变化实际发生于1995年的POSIX.1c标准之中)。如今,需要声明errno的程序必须包含<errno.h>,以启用对errno的线程级实现。
Pthreads函数返回值
从系统调用和库函数中返回状态,传统的做法是:返回0表示成功,返回-1表示失败,并设置errno以标识错误原因。Pthreads API则反其道而行之。所有Pthreads函数均以返回0表示成功,返回一正值表示失败。这一失败时的返回值,与传统UNIX系统调用置于errno中的值含义相同。
编译Pthreads程序
在Linux平台上,在编译调用了Pthreads API的程序时,需要设置cc -pthread的编译选项。使用该选项的效果如下。
- 定义_REENTRANT预处理宏。这会公开对少数可重入(reentrant)函数的声明。
- 程序会与库libpthread进行链接(等价于-lpthread)。
创建线程
启动程序时,产生的进程只有单条线程,称之为初始(initial)或主(main)线程。
函数pthread_create()负责创建一条新线程。
#include<pthread.h>
int pthread_create(pthread_t *thread,const pthread_attr_t *attr,(void*)(*start)(void*),void *arg);
新线程通过调用带有参数arg的函数start(即start(arg))而开始执行。调用pthread_create()的线程会继续执行该调用之后的语句。(这一行为与glibc库对系统调用clone()的包装函数行为相同。)
将参数arg声明为void*类型,意味着可以将指向任意对象的指针传递给start()函数。一般情况下,arg指向一个全局或堆变量,也可将其置为NULL。如果需要向start()传递多个参数,可以将arg指向一个结构,该结构的各个字段则对应于待传递的参数。通过审慎的类型强制转换,arg甚至可以传递int类型的值。
严格说来,对于int与void之间相互强制转换的后果,C语言标准并未加以定义。不过,大部分C语言编译器允许这样的操作,并且也能达成预期的目的,即int j == (int) ((void) j)。
start()的返回值类型为void*,对其使用方式与参数arg相同。对后续pthread_join()函数的描述中,将论及对该返回值的使用方式。
将经强制转换的整型数作为线程start函数的返回值时,必须小心谨慎。原因在于,取消线程时的返回值PTHREAD_CANCELED,通常是由实现所定义的整型值,再经强制转换为void*。若线程某甲的start函数将此整型值返回给正在执行pthread_join()操作的线程某乙,某乙会误认为某甲遭到了取消。应用如果采用了线程取消技术并选择将start函数的返回值强制转换为整型,那么就必须确保线程正常结束时的返回值与当前 Pthreads 实现中的PTHREAD_CANCELED不同。如欲保证程序的可移植性,则在任何将要运行该应用的实现中,正常退出线程的返回值应不同于相应的PTHREAD_CANCELED值。
参数thread指向pthread_t类型的缓冲区,在pthread_create()返回前,会在此保存一个该线程的唯一标识。后续的Pthreads函数将使用该标识来引用此线程。
SUSv3明确指出,在新线程开始执行之前,实现无需对thread参数所指向的缓冲区进行初始化,即新线程可能会在pthread_create()返回给调用者之前已经开始运行。如新线程需要获取自己的线程ID,则只能使用pthread_self()方法。
参数attr是指向pthread_attr_t对象的指针,该对象指定了新线程的各种属性。如果将attr设置为NULL,那么创建新线程时将使用各种默认属性,本书的大部分示例程序都采用这一做法。
调用pthread_create()后,应用程序无从确定系统接着会调度哪一个线程来使用CPU资源(在多处理器系统中,多个线程可能会在不同CPU上同时执行)。程序如隐含了对特定调度顺序的依赖,则无疑会对竞争条件打开方便之门。如果对执行顺序确有强制要求,那么就必须采用同步技术。
终止线程
可以如下方式终止线程的运行。
- 线程start函数执行return语句并返回指定值。
- 线程调用pthread_exit()。
- 调用pthread_cancel()取消线程。
- 任意线程调用了exit(),或者主线程执行了return语句(在main()函数中),都会导致进程中的所有线程立即终止。
pthread_exit()函数将终止调用线程,且其返回值可由另一线程通过调用pthread_join()来获取。
#include <pthread.h>
void pthread_exit(void* retval);
调用pthread_exit()相当于在线程的start函数中执行return,不同之处在于,可在线程start函数所调用的任意函数中调用pthread_exit() 。
参数retval指定了线程的返回值。Retval所指向的内容不应分配于线程栈中,因为线程终止后,将无法确定线程栈的内容是否有效。(例如,系统可能会立刻将该进程虚拟内存的这片区域重新分配,供一个新的线程栈使用。)出于同样的理由,也不应在线程栈中分配线程start函数的返回值。
如果主线程调用了pthread_exit(),而非调用exit()或是执行return语句,那么其他线程将继续运行。
线程ID(Thread ID)
进程内部的每个线程都有一个唯一标识,称为线程ID。线程ID会返回给pthread_create()的调用者,一个线程可以通过pthread_self()来获取自己的线程ID。
#include <pthread.h>
pthread_t pthread_self(void);
线程ID在应用程序中非常有用,原因如下。
- 不同的Pthreads函数利用线程ID来标识要操作的目标线程。这些函数包括pthread_ join()、pthread_detach()、pthread_cancel()和pthread_kill()等。
- 在一些应用程序中,以特定线程的线程ID作为动态数据结构的标签,这颇有用处,既可用来识别某个数据结构的创建者或属主线程,又可以确定随后对该数据结构执行操作的具体线程。
函数pthread_equal()可检查两个线程的ID是否相同。
#include <pthread.h>
int pthread_equal(pthread_t l1,pthread_t l2);
//相等返回非0数,不相等返回0
例如,为了检查调用线程的线程ID与保存于变量t1中的线程ID是否一致,可以编写如下代码:
if(pthread_equal(tid,pthread_self()))
printf("tid matches self\n");
因为必须将pthread_t作为一种不透明的数据类型加以对待,所以函数pthread_equal()是必须的。Linux将pthread_t定义为无符号长整型(unsigned long),但在其他实现中,则有可能是一个指针或结构。在NPTL中,pthread_t实际上是一个经强制转化而为无符号长整型的指针。
SUSv3并未要求将pthread_t实现为一个标量(scalar)类型,该类型也可以是一个结构。因此,下列显示线程ID的代码实例并不具有可移植性(尽管该实例在包括Linux在内的许多实现上均可正常运行,而且有时在调试程序时还很实用)。
在Linux的线程实现中,线程ID在所有进程中都是唯一的。不过在其他实现中则未必如此,SUSv3特别指出,应用程序若使用线程ID来标识其他进程的线程,其可移植性将无法得到保证。此外,在对已终止线程施以pthread_join(),或者在已分离(detached)线程退出后,实现可以复用该线程的线程ID。
POSIX线程ID与Linux专有的系统调用gettid()所返回的线程ID并不相同。POSIX线程ID由线程库实现来负责分配和维护。gettid()返回的线程ID是一个由内核(Kernel)分配的数字,类似于进程ID(process ID)。虽然在Linux NPTL线程实现中,每个POSIX线程都对应一个唯一的内核线程ID,但应用程序一般无需了解内核线程ID(况且,如果程序依赖于这一信息,也将无法移植)。
连接(joining)已终止的线程
函数pthread_join()等待由thread标识的线程终止。(如果线程已经终止,pthread_join()会立即返回)。这种操作被称为连接(joining)。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
若 retval 为一非空指针,将会保存线程终止时返回值的拷贝,该返回值亦即线程调用return或pthred_exit()时所指定的值。
如向pthread_join()传入一个之前已然连接过的线程ID,将会导致无法预知的行为。例如,相同的线程ID在参与一次连接后恰好为另一新建线程所重用,再度连接的可能就是这个新线程。
若线程并未分离,则必须使用ptherad_join()来进行连接。如果未能连接,那么线程终止时将产生僵尸线程,与僵尸进程(zombie process)的概念相类似。除了浪费系统资源以外,僵尸线程若累积过多,应用将再也无法创建新的线程。
pthread_join()执行的功能类似于针对进程的waitpid()调用,不过二者之间存在一些显著差别。
- 线程之间的关系是对等的(peers)。进程中的任意线程均可以调用pthread_join()与该进程的任何其他线程连接起来。例如,如果线程A创建线程B,线程B再创建线程C,那么线程A可以连接线程C,线程C也可以连接线程A。这与进程间的层次关系不同,父进程如果使用fork()创建了子进程,那么它也是唯一能够对子进程调用wait()的进程。调用pthread_create()创建的新线程与发起调用的线程之间,就没有这样的关系。
- 无法“连接任意线程”(对于进程,则可以通过调用waitpid(-1, &status, options)做到这一点),也不能以非阻塞(nonblocking)方式进行连接(类似于设置WHOHANG标志的waitpid())。使用条件(condition)变量可以实现类似的功能.
限制pthread_join()只能连接特定线程ID,这样做是“别有用心”的。其用意在于,程序应只能连接它所“知道的”线程。线程之间并无层次关系,如果听任“与任意线程连接”的操作发生,那么所谓“任意”线程就可以包括由库函数私自创建的线程,从而带来问题。结果是,函数库在获取线程返回状态时将不再能与该线程连接 ,只会一错再错,试图连接一个已然连接过的线程ID。换言之,“连接任意线程”的操作与模块化的程序设计理念背道而驰。
线程的分离
默认情况下,线程是可连接的(joinable),也就是说,当线程退出时,其他线程可以通过调用pthread_join()获取其返回状态。有时,程序员并不关心线程的返回状态,只是希望系统在线程终止时能够自动清理并移除之。在这种情况下,可以调用pthread_detach()并向thread参数传入指定线程的标识符,将该线程标记为处于分离(detached)状态。
#include <pthread.h>
int pthread_detach(pthread_t pthread);
如下例所示,使用pthread_detach(),线程可以自行分离:
pthread_detach(pthread_self());
一旦线程处于分离状态,就不能再使用pthread_join()来获取其状态,也无法使其重返“可连接”状态。
其他线程调用了exit(),或是主线程执行return语句时,即便遭到分离的线程也还是会受到影响。此时,不管线程处于可连接状态还是已分离状态,进程的所有线程会立即终止。换言之,pthread_detach()只是控制线程终止之后所发生的事情,而非何时或如何终止线程。
线程属性
前面已然提及pthread_create()中类型为pthread_attr_t的attr参数,可利用其在创建线程时指定新线程的属性。
线程VS进程
将应用程序实现为一组线程还是进程?本节将简单考虑一下可能影响这一决定的部分因素。先从多线程方法的优点开始。
- 线程间的数据共享很简单。相形之下,进程间的数据共享需要更多的投入。(例如,创建共享内存段或者使用管道pipe)。
- 创建线程要快于创建进程。线程间的上下文切换(context-switch),其消耗时间一般也比进程要短。
线程相对于进程的一些缺点如下所示。
- 多线程编程时,需要确保调用线程安全(thread-safe)的函数,或者以线程安全的方式来调用函数。(31.1节将讨论线程安全的概念。)多进程应用则无需关注这些。
- 某个线程中的bug(例如,通过一个错误的指针来修改内存)可能会危及该进程的所有线程,因为它们共享着相同的地址空间和其他属性。相比之下,进程间的隔离更彻底。
- 每个线程都在争用宿主进程(host process)中有限的虚拟地址空间。特别是,一旦每个线程栈以及线程特有数据(或线程本地存储)消耗掉进程虚拟地址空间的一部分,则后续线程将无缘使用这些区域。虽然有效地址空间很大(例如,在x86-32平台上通常有3GB),但当进程分配大量线程,亦或线程使用大量内存时,这一因素的限制作用也就突显出来。与之相反,每个进程都可以使用全部的有效虚拟内存,仅受制于实际内存和交换(swap)空间。
影响选择的还有如下几点。
- 在多线程应用中处理信号,需要小心设计。(作为通则,一般建议在多线程程序中避免使用信号。)
- 在多线程应用中,所有线程必须运行同一个程序(尽管可能是位于不同函数中)。对于多进程应用,不同的进程可以运行不同的程序。
- 除了数据,线程还可以共享某些其他信息(例如,文件描述符、信号处置、当前工作目录,以及用户ID和组ID)。优劣之判,视应用而定。
线程:线程同步
保护对共享变量的访问:互斥量
线程的主要优势在于,能够通过全局变量来共享信息。不过,这种便捷的共享是有代价的:必须确保多个线程不会同时修改同一变量,或者某一线程不会读取正由其他线程修改的变量。术语临界区(critical section)是指访问某一共享资源的代码片段,并且这段代码的执行应为原子(atomic)操作,亦即,同时访问同一共享资源的其他线程不应中断该片段的执行。
以非原子方式访问会发生如下问题。
这一行为的不确定性,实应归咎于内核CPU调度决定的难以预见。若在复杂程序中发生这一不确定行为,则意味着此类错误将偶尔发作,难以重现,因此也很难发现。
为避免线程更新共享变量时所出现问题,必须使用互斥量(mutex是mutual exclusion的缩写)来确保同时仅有一个线程可以访问某项共享资源。更为全面的说法是,可以使用互斥量来保证对任意共享资源的原子访问,而保护共享变量是其最常见的用法。
互斥量有两种状态:已锁定(locked)和未锁定(unlocked)。任何时候,至多只有一个线程可以锁定该互斥量。试图对已经锁定的某一互斥量再次加锁,将可能阻塞线程或者报错失败,具体取决于加锁时使用的方法。
一旦线程锁定互斥量,随即成为该互斥量的所有者。只有所有者才能给互斥量解锁。这一属性改善了使用互斥量的代码结构,也顾及到对互斥量实现的优化。因为所有权的关系,有时会使用术语获取(acquire)和释放(release)来替代加锁和解锁。
一般情况下,对每一共享资源(可能由多个相关变量组成)会使用不同的互斥量,每一线程在访问同一资源时将采用如下协议。
- 针对共享资源锁定互斥量。
- 访问共享资源。
- 对互斥量解锁。
如果多个线程试图执行这一代码块(一个临界区),事实上只有一个线程能够持有该互斥量(其他线程将遭到阻塞),即同时只有一个线程能够进入这段代码区域。
最后请注意,使用互斥锁仅是一种建议,而非强制。亦即,线程可以考虑不使用互斥量而仅访问相应的共享变量。为了安全地处理共享变量,所有线程在使用互斥量时必须互相协调,遵守既定的锁定规则。
静态分配的互斥量
互斥量既可以像静态变量那样分配,也可以在运行时动态创建(例如,通过malloc()在一块内存中分配)。动态互斥量的创建稍微有些复杂,将延后讨论。
互斥量是属于pthread_mutex_t类型的变量。在使用之前必须对其初始化。对于静态分配的互斥量而言,可如下例所示,将PTHREAD_MUTEX_INITIALIZER赋给互斥量。
pthread_mutex_t mtx=PTHREAD_MUTEX_INITIALIZER;
加锁和解锁互斥量
初始化之后,互斥量处于未锁定状态。函数pthread_mutex_lock()可以锁定某一互斥量,而函数pthread_mutex_unlock()则可以将一个互斥量解锁。
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
要锁定互斥量,在调用pthread_mutex_lock()时需要指定互斥量。如果互斥量当前处于未锁定状态,该调用将锁定互斥量并立即返回。如果其他线程已经锁定了这一互斥量,那么pthread_mutex_lock()调用会一直堵塞,直至该互斥量被解锁,到那时,调用将锁定互斥量并返回。
如果发起pthread_mutex_lock()调用的线程自身之前已然将目标互斥量锁定,对于互斥量的默认类型而言,可能会产生两种后果——视具体实现而定:线程陷入死锁(deadlock),因试图锁定已为自己所持有的互斥量而遭到阻塞;或者调用失败,返回EDEADLK错误。在Linux上,默认情况下线程会发生死锁。
函数pthread_mutex_unlock()将解锁之前已遭调用线程锁定的互斥量。以下行为均属错误:对处于未锁定状态的互斥量进行解锁,或者解锁由其他线程锁定的互斥量。
如果有不止一个线程在等待获取由函数pthread_mutex_unlock()解锁的互斥量,则无法判断究竟哪个线程将如愿以偿。
pthread_mutex_trylock()和pthread_mutex_timedlock()
Pthreads API提供了pthread_mutex_lock()函数的两个变体:pthread_mutex_trylock()和pthread_mutex_timedlock()。可参考手册页(manual page)获取这些函数的原型。
如果信号量已然锁定,对其执行函数pthread_mutex_trylock()会失败并返回EBUSY错误,除此之外,该函数与pthread_mutex_lock()行为相同。
除了调用者可以指定一个附加参数 abstime(设置线程等待获取互斥量时休眠的时间限制)外,函数pthread_mutex_timedlock()与pthread_mutex_lock()没有差别。如果参数abstime指定的时间间隔期满,而调用线程又没有获得对互斥量的所有权,那么函数pthread_mutex_timedlock()返回ETIMEDOUT错误。
函数pthread_mutex_trylock() 和 pthread_mutex_timedlock()比pthread_mutex_lock()的使用频率要低很多。在大多数经过良好设计的应用程序中,线程对互斥量的持有时间应尽可能短,以避免妨碍其他线程的并发执行。这也保证了遭堵塞的其他线程可以很快获取对互斥量的锁定。若某一线程使用pthread_mutex_trylock()周期性地轮询是否可以对互斥量加锁,则有可能要承担这样的风险:当队列中的其他线程通过调用pthread_mutex_lock()相继获得对互斥量的访问时,该线程将始终与此互斥量无缘。
互斥量的性能
使用互斥量的开销有多大?前面已经展示了递增共享变量程序的两个不同版本:没有使用互斥量的程序清单 30-1 和使用互斥量的程序清单30-2。在x86-32架构的Linux 2.6.31(含NPTL)系统下运行这两个程序,如令单一线程循环1000万次,前者共花费了0.35秒(并产生错误结果),而后者则需要3.1秒。
乍看起来,代价极高。不过,考虑一下前者执行的主循环。在该版本中,函数threadFunc()于for循环中,先递增循环控制变量,再将其与另一变量进行比较,随后执行两个复制操作和一个递增操作,最后返回循环起始处开始下一次循环。而后者——使用互斥量的版本执行了相同步骤,不过在每次循环的前后多了加锁和解锁互斥量的工作。换言之,对互斥量加锁和解锁的开销略低于第1个程序的10次循环操作。成本相对比较低廉。此外,在通常情况下,线程会花费更多时间去做其他工作,对互斥量的加锁和解锁操作相对要少得多,因此使用互斥量对于大部分应用程序的性能并无显著影响。
进而言之,在相同系统上运行一些简单的测试程序,结果显示,如将使用函数 fcntl()加锁、解锁一片文件区域的代码循环2000万次,需耗时44秒,而将对系统V信号量(semaphore)的递增和递减代码循环2000万次,则需要28秒。文件锁和信号量的问题在于,其锁定和解锁总是需要发起系统调用(system call),而每个系统调用的开销虽小,但颇为可观。与之相反,互斥量的实现采用了机器语言级的原子操作(在内存中执行,对所有线程可见),只有发生锁的争用时才会执行系统调用。
互斥量的死锁
有时,一个线程需要同时访问两个或更多不同的共享资源,而每个资源又都由不同的互斥量管理。当超过一个线程加锁同一组互斥量时,就有可能发生死锁。下图展示了一个死锁的例子,其中每个线程都成功地锁住一个互斥量,接着试图对已为另一线程锁定的互斥量加锁。两个线程将无限期地等待下去。
要避免此类死锁问题,最简单的方法是定义互斥量的层级关系。当多个线程对一组互斥量操作时,总是应该以相同顺序对该组互斥量进行锁定。例如,在上图所示场景中,如果两个线程总是先锁定mutex1再锁定mutex2,死锁就不会出现。有时,互斥量间的层级关系逻辑清晰。不过,即便没有,依然可以设计出所有线程都必须遵循的强制层级顺序。
另一种方案的使用频率较低,就是“尝试一下,然后恢复”。在这种方案中,线程先使用函数pthread_mutex_lock()锁定第1个互斥量,然后使用函数pthread_mutex_trylock()来锁定其余互斥量。如果任一pthread_mutex_trylock()调用失败(返回EBUSY),那么该线程将释放所有互斥量,也许经过一段时间间隔,从头再试。较之于按锁的层级关系来规避死锁,这种方法效率要低一些,因为可能需要历经多次循环。另一方面,由于无需受制于严格的互斥量层级关系,该方法也更为灵活。[Butenhof, 1996]中载有这一方案的范例。
动态初始化互斥量
静态初始值PTHREAD_MUTEX_INITIALIZER,只能用于对如下互斥量进行初始化:经由静态分配且携带默认属性。其他情况下,必须调用pthread_mutex_init()对互斥量进行动态初始化。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
参数mutex指定函数执行初始化操作的目标互斥量。参数 attr 是指向pthread_mutexattr_t类型对象的指针,该对象在函数调用之前已经过了初始化处理,用于定义互斥量的属性。(下节会介绍更多互斥量属性。)若将attr参数置为NULL,则该互斥量的各种属性会取默认值。
SUSv3规定,初始化一个业已初始化的互斥量将导致未定义的行为,应当避免这一行为。
在如下情况下,必须使用函数pthread_mutex_init(),而非静态初始化互斥量。
- 动态分配于堆中的互斥量。例如,动态创建针对某一结构的链表,表中每个结构都包含一个pthread_mutex_t类型的字段来存放互斥量,借以保护对该结构的访问。
- 互斥量是在栈中分配的自动变量。
- 初始化经由静态分配,且不使用默认属性的互斥量。
当不再需要经由自动或动态分配的互斥量时,应使用 pthread_mutex_destroy()将其销毁。(对于使用PTHREAD_MUTEX_INITIALIZER静态初始化的互斥量,无需调用pthread_mutex_destroy()。)
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
只有当互斥量处于未锁定状态,且后续也无任何线程企图锁定它时,将其销毁才是安全的。若互斥量驻留于动态分配的一片内存区域中,应在释放(free)此内存区域前将其销毁。对于自动分配的互斥量,也应在宿主函数返回前将其销毁。
经由pthread_mutex_destroy()销毁的互斥量,可调用pthread_mutex_init()对其重新初始化。
#include <pthread.h>
#include <stdio.h>
#include<unistd.h>
pthread_mutex_t mutex ;
void *print_msg(void *arg){
int i=0;
pthread_mutex_lock(&mutex); //mutex加锁
for(i=0;i<20;i++)
{
printf("output : %d\n",i);
sleep(1);
}
pthread_mutex_unlock(&mutex); //mutex解锁
}
int main(int argc,char** argv)
{
pthread_t id1;
pthread_t id2;
pthread_mutex_init(&mutex,NULL);
pthread_create(&id1,NULL,print_msg,NULL);
pthread_create(&id2,NULL,print_msg,NULL);
pthread_join(id1,NULL); //守护thread1结束
pthread_join(id2,NULL); //守护thread2结束
pthread_mutex_destroy(&mutex);
return 0;
}
g++ main.cpp -pthread
output : 0
output : 1
output : 2
output : 3
output : 4
output : 5
output : 6
...
程序清单使用了一个互斥量来保护对变量i的访问。
互斥量的属性
如前所述,可以在pthread_mutex_init()函数的arg参数中指定pthread_mutexattr_t类型对象,对互斥量的属性进行定义。
互斥量类型
前面几页对互斥量的行为做了若干论述。
- 同一线程不应对同一互斥量加锁两次。
- 线程应为自己所拥有的互斥量解锁(亦即,尚未锁定互斥量)。
- 线程不应对一尚未锁定的互斥量做解锁动作。
准确地说,上述情况的结果将取决于互斥量类型(type)。SUSv3定义了以下互斥量类型。
PTHREAD_MUTEX_NORMAL
该类型的互斥量不具有死锁检测(自检)功能。如线程试图对已由自己锁定的互斥量加锁,则发生死锁。互斥量处于未锁定状态,或者已由其他线程锁定,对其解锁会导致不确定的结果。(在Linux上,对这类互斥量的上述两种操作都会成功。)
PTHREAD_MUTEX_ERRORCHECK
对此类互斥量的所有操作都会执行错误检查。所有上述3种情况都会导致相关Pthreads函数返回错误。这类互斥量运行起来比一般类型要慢,不过可将其作为调试工具,以发现程序在哪里违反了互斥量使用的基本原则。
PTHREAD_MUTEX_RECURSIVE
递归互斥量维护有一个锁计数器。当线程第1次取得互斥量时,会将锁计数器置1。后续由同一线程执行的每次加锁操作会递增锁计数器的数值,而解锁操作则递减计数器计数。只有当锁计数器值降至0时,才会释放(release,亦即可为其他线程所用)该互斥量。解锁时如目标互斥量处于未锁定状态,或是已由其他线程锁定,操作都会失败。
Linux的线程实现针对以上各种类型的互斥量提供了非标准的静态初始值(例如,PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP),以便对那些通过静态分配的互斥量进行初始化,而无需使用pthread_mutex_init()函数。不过,为保证程序的可移植性,应该避免使用这些初始值。
除了上述类型,SUSv3还定义了PTHREAD_MUTEX_DEFAULT类型。使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量,或是经调用参数attr为NULL的pthread_mutex_init()函数所创建的互斥量,都属于此类型。至于该类型互斥量在本节开始处3个场景中的行为,规范有意未作定义,意在为互斥量的高效实现保留最大的灵活性。Linux上,PTHREAD_MUTEX_DEFAULT类型互斥量的行为与PTHREAD_MUTEX_NORMAL类型相仿。
s=pthread_mutexattr_settype(&mtxAttr,PTHREAD_MUTEX_ERRORCHECK);
if(s != 0)
errExitEN(s,"pthread_mutexattr_settype");
s=pthread_mutex_init(mtx,&mtxAttr);
if(s!=0)
errExitEN(s,"pthread_mutex_init");
s=pthread_mutexattr_destroy(&mtxAttr);
if(s!=0)
errExitEN(s,"pthread_mutexattr_destroy");
通知状态的改变:条件变量(Condition Variable)
互斥量防止多个线程同时访问同一共享变量。条件变量允许一个线程就某个共享变量(或其他共享资源)的状态变化通知其他线程,并让其他线程等待(堵塞于)这一通知。
条件变量总是结合互斥量使用。条件变量就共享变量的状态改变发出通知,而互斥量则提供对该共享变量访问的互斥(mutual exclusion)。这里使用的术语“信号”(signal),与第20章至第22章所述信号(signal)无关,而是发出信号的意思。
由静态分配的条件变量
如同互斥量一样,条件变量的分配,有静态和动态之分。
条件变量的数据类型是pthread_count_t。类似于互斥量,使用条件变量前必须对其初始化。对于经由静态分配的条件变量,将其赋值为PTHREAD_COND_INITALIZER即完成初始化操作。可参考下面的例子:
pthread_count_t count=PTHREAD_COND_INITALIZER;
通知和等待条件变量
条件变量的主要操作是发送信号(signal)和等待(wait)。发送信号操作即通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变。等待操作是指在收到一个通知前一直处于阻塞状态。
函数pthread_cond_signal()和pthread_cond_broadcast()均可针对由参数cond所指定的条件变量而发送信号。pthread_cond_wait()函数将阻塞一线程,直至收到条件变量 cond的通知。
#include<pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
函数pthread_cond_signal()和pthread_cond_broadcast()之间的差别在于,二者对阻塞于pthread_cond_wait()的多个线程处理方式不同。pthread_cond_signal()函数只保证唤醒至少一条遭到阻塞的线程,而pthread_cond_broadcast()则会唤醒所有遭阻塞的线程。
使用函数pthread_cond_broadcast()总能产生正确结果(因为所有线程应都能处理多余和虚假的唤醒动作),但函数pthread_cond_signal()会更为高效。不过,只有当仅需唤醒一条(且无论是其中哪条)等待线程来处理共享变量的状态变化时,才应使用 pthread_cond_signal()。应用这种方式的典型情况是,所有等待线程都在执行完全相同的任务。基于这些假设,函数pthread_cond_signal()会比pthread_cond_broadcast()更具效率,因为这可以避免发生如下情况。
1. 同时唤醒所有等待线程。
2. 某一线程首先获得调度。此线程检查了共享变量的状态(在相关互斥量的保护之下),发现还有任务需要完成。该线程执行了所需工作,并改变共享变量状态,以表明任务完成,最后释放对相关互斥量的锁定。
3. 剩余的每个线程轮流锁定互斥量并检测共享变量的状态。不过,由于第一个线程所做的工作,余下的线程发现无事可做,随即解锁互斥量转而休眠(即再次调用 pthread_cond_wait())。
相形之下,函数pthread_cond_broadcast()所处理的情况是:处于等待状态的所有线程执行的任务不同(即各线程关联于条件变量的判定条件不同)。
条件变量并不保存状态信息,只是传递应用程序状态信息的一种通讯机制。发送信号时若无任何线程在等待该条件变量,这个信号也就会不了了之。线程如在此后等待该条件变量,只有当再次收到此变量的下一信号时,方可解除阻塞状态。
函数pthread_cond_timedwait()与函数pthread_cond_wait()几近相同,唯一的区别在于,由参数abstime来指定一个线程等待条件变量通知时休眠时间的上限。
#include<pthread.h>
int pthread_cond_timedwait(pthread_cond_t *cond,pthread_mutex_t *mutex,const struct timespec *abstime);
参数abstime是一个timespec类型的结构,用以指定自Epoch以来以秒和纳秒(nanosecond)为单位表示的绝对(absolute)时间。如果abstime指定的时间间隔到期且无相关条件变量的通知,则返回ETIMEOUT错误。
例1:
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
static pthread_mutex_t mtx=PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
struct node {
int n_number;
struct node *n_next;
} *head=NULL; /*[thread_func]*/
/*释放节点内存*/
static void cleanup_handler(void*arg) {
printf("Clean up handler of second thread.\n");
free(arg);
(void)pthread_mutex_unlock(&mtx);
}
static void *thread_func(void *arg) {
struct node*p=NULL;
pthread_cleanup_push(cleanup_handler,p);
pthread_mutex_lock(&mtx);
//这个mutex_lock主要是用来保护wait等待临界时期的情况,
//当在wait为放入队列时,这时,已经存在Head条件等待激活
//的条件,此时可能会漏掉这种处理
//这个while要特别说明一下,单个pthread_cond_wait功能很完善,
//为何这里要有一个while(head==NULL)呢?因为pthread_cond_wait
//里的线程可能会被意外唤醒,如果这个时候head==NULL,
//则不是我们想要的情况。这个时候,
//应该让线程继续进入pthread_cond_wait
while(1) {
while(head==NULL) {
pthread_cond_wait(&cond,&mtx);
}
//pthread_cond_wait会先解除之前的pthread_mutex_lock锁定的mtx,
//然后阻塞在等待队列里休眠,直到再次被唤醒
//(大多数情况下是等待的条件成立而被唤醒,唤醒后,
//该进程会先锁定先pthread_mutex_lock(&mtx);,
//再读取资源用这个流程是比较清楚的
/*block-->unlock-->wait()return-->lock*/
p=head;
head=head->n_next;
printf("Got%dfromfrontofqueue\n",p->n_number);
free(p);
}
pthread_mutex_unlock(&mtx);//临界区数据操作完毕,释放互斥锁
pthread_cleanup_pop(0);
return 0;
}
int main(void) {
pthread_t tid;
int i;
struct node *p;
pthread_create(&tid,NULL,thread_func,NULL);
//子线程会一直等待资源,类似生产者和消费者,
//但是这里的消费者可以是多个消费者,
//而不仅仅支持普通的单个消费者,这个模型虽然简单,
//但是很强大
for(i=0;i<10;i++) {
p=(struct node*)malloc(sizeof(struct node));
p->n_number=i;
pthread_mutex_lock(&mtx);//需要操作head这个临界资源,先加锁,
p->n_next=head;
head=p;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mtx);//解锁
sleep(1);
}
printf("thread1wannaendthecancelthread2.\n");
pthread_cancel(tid);
//关于pthread_cancel,有一点额外的说明,它是从外部终止子线程,
//子线程会在最近的取消点,退出线程,而在我们的代码里,最近的
//取消点肯定就是pthread_cond_wait()了。
pthread_join(tid,NULL);
printf("Alldone--exiting\n");
return 0;
}
g++ main.cpp -pthread
Got0fromfrontofqueue
Got1fromfrontofqueue
Got2fromfrontofqueue
Got3fromfrontofqueue
Got4fromfrontofqueue
Got5fromfrontofqueue
Got6fromfrontofqueue
Got7fromfrontofqueue
Got8fromfrontofqueue
Got9fromfrontofqueue
thread1wannaendthecancelthread2.
Clean up handler of second thread.
Alldone--exiting
测试条件变量的判断条件(predicate)
每个条件变量都有与之相关的判断条件,涉及一个或多个共享变量。例如,在上一节的代码中,与cond相关的判断是(avail == 0)。这段代码展示了一个通用的设计原则:必须由一个while循环,而不是if语句,来控制对pthread_cond_wait()的调用。这是因为,当代码从pthread_cond_wait()返回时,并不能确定判断条件的状态,所以应该立即重新检查判断条件,在条件不满足的情况下继续休眠等待。
从pthread_cond_wait()返回时,之所以不能对判断条件的状态做任何假设,其理由如下。
- 其他线程可能会率先醒来。也许有多个线程在等待获取与条件变量相关的互斥量。即使就互斥量发出通知的线程将判断条件置为预期状态,其他线程依然有可能率先获取互斥量并改变相关共享变量的状态,进而改变判断条件的状态。
- 设计时设置“宽松的”判断条件或许更为简单。有时,用条件变量来表征可能性而非确定性,在设计应用程序时会更为简单。换言之,就条件变量发送信号意味着“可能有些事情”需要接收信号的线程去响应,而不是“一定有一些事情”要做。使用这种方法,可以基于判断条件的近似情况来发送条件变量通知,接收信号的线程可以通过再次检查判断条件来确定是否真的需要做些什么。
- 可能会发生虚假唤醒的情况。在一些实现中,即使没有任何其他线程真地就条件变量发出信号,等待此条件变量的线程仍有可能醒来。在一些多处理器系统上,为确保高效实现而采用的技术会导致此类(不常见的)虚假唤醒。SUSv3对此予以明确认可。
动态分配的条件变量
使用函数 pthread_cond_init()对条件变量进行动态初始化。需要使用 pthread_cond_init()的情形类似于使用 pthread_mutex_init()来动态初始化互斥量的情况。亦即,对自动或动态分配的条件变量进行初始化时,或是对未采用默认属性经由静态分配的条件变量进行初始化时,必须使用pthread_cond_init()。
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
参数cond表示将要初始化的目标条件变量。类似于互斥量,可以指定之前经由初始化处理的 attr 参数来判定条件变量的属性。对于 attr 所指向的 pthread_condattr_t 类型对象,可使用多个Pthreads函数对其中属性进行初始化。若将attr置为NULL,则使用一组缺省属性来设置条件变量。
SUSv3规定,对业已初始化的条件变量进行再次初始化,将导致未定义的行为。应当避免这一做法。
当不再需要一个经由自动或动态分配的条件变量时,应调用pthread_cond_destroy()函数予以销毁。对于使用PTHREAD_COND_INITIALIZER进行静态初始化的条件变量,无需调用pthread_cond_destroy()。
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
对某个条件变量而言,仅当没有任何线程在等待它时,将其销毁才是安全的。如果条件变量驻留于某片动态创建的内存区域,那么应在释放该内存区域前就将其销毁。经由自动分配的条件变量应在宿主函数返回前予以销毁。
经pthread_cond_destroy()销毁的条件变量,之后可以调用pthread_cond_init()对其进行重新初始化。
线程:线程安全和每线程存储
线程安全(再论可重入性)
若函数可同时供多个线程安全调用,则称之为线程安全函数;反之,如果函数不是线程安全的,则不能并发调用。
实现线程安全有多种方式。其一是将函数与互斥量关联使用(如果函数库中的所有函数都共享同样的全局变量,那么或许应将所有函数都与该互斥量相关联),在调用函数时将其锁定,在函数返回时解锁。这一方法的优点在于简单。另一方面,这也意味着同时只能有一个线程执行该函数,亦即,对该函数的访问是串行的(serialized)。如果各线程在执行此函数时都耗费了相当多的时间,那么串行化会导致并发能力的丧失,所有线程将不再并发执行。
另一种更为复杂的解决方案是:将共享变量与互斥量关联起来。这需要程序员们确认函数的哪些部分是使用了共享变量的临界区,且仅在执行到临界区时去获取和释放互斥量。这将允许多线程同时执行一个函数并实现并行,除非出现多个线程需要同时执行同一临界区的情况。
非线程安全的函数
为便于开发多线程应用程序,除下表所列函数以外,SUSv3中的所有函数都需实现线程安全。
函数 | 函数 | 函数 | 函数 |
---|---|---|---|
asctime() | fcvt() | getpwnam() | nl_langinfo() |
basename() | ftw() | getpwuid() | ptsname() |
catgets() | gcvt() | getservbyname() | putc_unlocked() |
crypt() | getc_unlocked() | getservbyport() | putchar_unlocked() |
ctime() | getchar_unlocked() | getservent() | putenv() |
dbm_clearerr() | getdate() | getutxent() | pututxline() |
dbm_close() | getenv() | getutxid() | rand() |
dbm_delete() | getgrent() | getutxline() | () |
dbm_error() | getgrgid() | gmtime() | setenv() |
dbm_fetch() | getgrnam() | hcreate() | setgrent() |
dbm_firstkey() | gethostbyaddr() | hdestroy() | setkey() |
dbm_nextkey() | gethostbyname() | hsearch() | setpwent() |
dbm_open() | gethostent() | inet_ntoa() | setutxent() |
dbm_store() | getlogin() | l64a() | strerror() |
dirname() | getnetbyaddr() | lgamma() | strtok() |
dlerror() | getnetbyname() | lgammaf() | ttyname() |
drand48() | getnetent() | lgammal() | unsetenv() |
ecvt() | getopt() | localeconv() | wcstombs() |
encrypt() | getprotobyname() | localtime() | wctomb() |
endgrent() | getprotobynumber() | lrand48() | |
endpwent() | getprotoent() | mrand48() | |
endutxent() | getpwent() | nftw() |
可重入和不可重入函数
较之于对整个函数使用互斥量,使用临界区实现线程安全虽然有明显改进,但由于存在对互斥量的加锁和解锁开销,所以多少还是有些低效。可重入函数则无需使用互斥量即可实现线程安全。其要诀在于避免对全局和静态变量的使用。需要返回给调用者的任何信息,亦或是需要在对函数的历次调用间加以维护的信息,都存储于由调用者分配的缓冲区内。不过,并非所有函数都可以实现为可重入,通常的原因如下。
- 根据其性质,有些函数必须访问全局数据结构。malloc 函数库中的函数就是这方面的典范。这些函数为堆中的空闲块维护有一个全局链表。malloc 库函数的线程安全是通过使用互斥量来实现的。
- 一些函数(在发明线程之前就已问世)的接口本身就定义为不可重入,要么返回指针,指向由函数自身静态分配的存储空间,要么利用静态存储对该函数(或相关函数)历次调用间的信息加以维护。上表 所列函数大多属于此类。例如,函数asctime()(10.2.3节)就返回一个指针,指向经由静态分配的缓冲区,其内容为日期和时间字符串。
对于一些接口不可重入的函数,SUSv3为其定义了以后缀_r结尾的可重入“替身”。这些“替身”函数要求由调用者来分配缓冲区,并将缓存区地址传给函数用以返回结果。这使得调用线程可以使用局部(栈)变量来存放函数结果。出于这一目的,SUSv3定义了如下函数:asctime_r()、ctime_r()、getgrgid_r()、getgrnam_r()、getlogin_r()、getpwnam_r()、getpwuid_r()、gmtime_r()、localtime_r()、rand_r()、readdir_r()、strerror_r()、strtok_r()和ttyname_r()。
一次性初始化
多线程程序有时有这样的需求:不管创建了多少线程,有些初始化动作只能发生一次。例如,可能需要执行 pthread_mutex_init()对带有特殊属性的互斥量进行初始化,而且必须只能初始化一次。如果由主线程来创建新线程,那么这一点易如反掌:可以在创建依赖于该初始化的线程之前进行初始化。不过,对于库函数而言,这样处理就不可行,因为调用者在初次调用库函数之前可能已经创建了这些线程。故而需要这样的库函数:无论首次为任何线程所调用,都会执行初始化动作。
库函数可以通过函数pthread_once()实现一次性初始化。
#include<pthread.h>
int pthread_once(pthread_once_t *once_control, void (*init) (void));
利用参数 once_control 的状态,函数 pthread_once()可以确保无论有多少线程对pthread_once()调用了多少次,也只会执行一次由init指向的调用者定义函数。
init函数没有任何参数,形式如下:
void init(void){
/* do somethings */
}
另外,参数 once_control 必须是一指针,指向初始化为 PTHREAD_ONCE_INIT 的静态变量。
pthread_once_t once_var=PTHREAD_ONCE_INIT;
调用函数pthread_once()时要指定一个指针,指向类型为pthread_once_t的特定变量,对该函数的首次调用将修改once_control所指向的内容,以便对其后续调用不会再次执行init。
常常将Pthread_once()和线程特有数据结合使用.
Pthreads 的早期版本不能对互斥量进行静态初始化,只能使用 pthread_mutex_init()([Butenbof,1996]),这也是函数 pthread_once()存在的主要原因。随着静态分配互斥量功能的问世,库函数可以使用一个经静态分配的互斥量和一个静态布尔型(Boolean)变量来实现一次性初始化。虽然如此,出于方便的考虑,函数pthread_once()得以保留。
线程特有数据
在单线程程序中,我们经常要使用全局变量来实现多个函数间共享数据。在多线程环境下,由于数据空间是共享的,因此全局变量也为所有线程所共有。但有时在应用程序设计中有必要提供线程私有的全局变量,仅在某个线程中有效,但可以跨多个函数访问,这样每个线程访问它自己独立的数据空间,而不用担心和其它线程的同步访问。
这样在一个线程内部的各个函数都能访问、但其它线程不能访问的变量,我们就需要使用线程局部静态变量(Static memory local to a thread) 同时也可称之为线程特有数据(Thread-Specific Data 或 TSD),或者线程局部存储(Thread-Local Storage 或 TLS)。
实现函数线程安全最为有效的方式就是使其可重入,应以这种方式来实现所有新的函数库。不过,对于已有的不可重入函数库(可能问世于线程流行之前)来说,采用这种方法通常需要修改函数接口,这也意味着,需要修改所有使用此类函数的应用程序。
使用线程特有数据技术,可以无需修改函数接口而实现已有函数的线程安全。较之于可重入函数,采用线程特有数据的函数效率可能要略低一些,不过对于使用了这些调用的程序而言,则省去了修改程序之劳。
如图31-1所示,线程特有数据使函数得以为每个调用线程分别维护一份变量的副本(copy)。线程特有数据是长期存在的。在同一线程对相同函数的历次调用间,每个线程的变量会持续存在,函数可以向每个调用线程返回各自的结果缓冲区(如果需要的话)。
库函数视角下的线程特有数据
要了解线程特有数据相关API的使用,需要从使用这一技术的库函数角度来考虑如下问题。
- 该函数必须为每个调用者线程分配单独的存储,且只需在线程初次调用此函数时分配一次即可。
- 在同一线程对此函数的后续所有调用中,该函数都需要获取初次调用时线程分配的存储块地址。由于函数调用结束时会释放自动变量,故而函数不应利用自动变量存放存储块指针,也不能将指针存放于静态变量中,因为静态变量在进程中只有一个实例。Pthreads API提供了函数来处理这一情况。
- 不同(无相互依赖关系)函数各自可能都需要使用线程特有数据。每个函数都需要方法来标识其自身的线程特有数据(键),以便与其他函数所使用的线程特有数据有所区分。
- 当线程退出时,函数无法控制将要发生的情况。这时,线程可能会执行该函数之外的代码。不过,一定存在某些机制(解构器),在线程退出时会自动释放为该线程所分配的存储。若非如此,随着持续不断地创建线程,调用函数和终止线程,将会引发内存泄露。
线程特有数据API概述
要使用线程特有数据,库函数执行的一般步骤如下。
1. 函数创建一个键(key),用以将不同函数使用的线程特有数据项区分开来。调用函数pthread_key_create()可创建此“键”,且只需在首个调用该函数的线程中创建一次,函数pthread_once()的使用正是出于这一目的。键在创建时并未分配任何线程特有数据块。
2. 调用pthread_key_create()还有另一个目的,即允许调用者指定一个自定义解构函数,用于释放为该键所分配的各个存储块(参见下一步)。当使用线程特有数据的线程终止时,Pthreads API会自动调用此解构函数,同时将该线程的数据块指针作为参数传入。
3. 函数会为每个调用者线程创建线程特有数据块。这一分配通过调用malloc()(或类似函数)完成,每个线程只分配一次,且只会在线程初次调用此函数时分配。
4. 为了保存上一步所分配存储块的地址,函数会使用两个Pthreads函数:pthread_setspecific()和pthread_getspecific()。调用函数pthread_setspecific()实际上是对Pthreads实现发起这样的请求:保存该指针,并记录其与特定键(该函数的键)以及特定线程(调用者线程)的关联性。调用pthread_getspecific()所执行的是互补操作:返回之前所保存的、与给定键以及调用线程相关联的指针。如果还没有指针与特定的键及线程相关联,那么pthread_getspecific()返回NULL。函数可以利用这一点来判断自身是否是初次为某个线程所调用,若为初次,则必须为该线程分配空间。#
线程特有数据API详述
调用pthread_key_create()函数为线程特有数据创建一个新键,并通过key所指向的缓冲区返回给调用者。
因为进程中的所有线程都可使用返回的键,所以参数key应指向一个全局变量。
#include <pthread.h>
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
参数destructor指向一个自定义函数,其格式如下:
void dest(void *valure)
{
/* Release storage pointed to by 'value'*/
}
只要线程终止时与key的关联值不为NULL,Pthreads API会自动执行解构函数,并将与key的关联值作为参数传入解构函数。传入的值通常是与该键关联,且指向线程特有数据块的指针。如果无需解构,那么可将destructor设置为NULL。
如果一个线程有多个线程特有数据块,那么对各个解构函数的调用顺序是不确定的。对每个解构函数的设计应相互独立。
观察线程特有数据的实现有助于理解它们的使用方法。典型的实现(NPTL即在此列)会包含以下数组。
- 一个全局(进程范围)数组,存放线程特有数据的键信息。
- 每个线程包含一个数组,存有为每个线程分配的线程特有数据块的指针(通过调用pthread_ setspecific()来存储指针)。
在这一实现中,pthread_key_create()返回的pthread_key_t类型值只是对全局数组的索引(index),标记为pthread_keys,其格式如图所示。数组的每个元素都是一个包含两个字段(field)的结构。第一个字段标记该数组元素是否在用(即已由之前对pthread_key_create()的调用分配)。第二个字段用于存放针对此键、线程特有数据块的解构函数指针(是函数pthread_key_crate()中参数destructor的一份拷贝)。
函数pthread_setspecific()要求Pthreads API将value的副本存储于一数据结构中,并将value与调用线程以及key相关联(key由之前对pthread_key_create()的调用返回)。Pthread_getspecific()函数执行的操作与之相反,返回之前与本线程及给定key相关的值(value)。
#include<pthread.h>
int pthread_setspecific(pthread_key_t key,const void *value);
void *pthread_getspecific(pthread_key_t key);
函数pthread_setspecific()的参数value通常是一指针,指向由调用者分配的一块内存。当线程终止时,会将该指针作为参数传递给与key对应的解构函数。
参数 value 也可以不是一个指向内存区域的指针,而是任何可以赋值(通过强制转换)给 void*的标量值。在这种情况下,先前对 pthread_key_create()函数的调用应将destructor指定为NULL。
当线程刚刚创建时,会将所有线程特有数据的指针都初始化为NULL。这意味着当线程初次调用库函数时,必须使用pthread_getspecific()函数来检查该线程是否已有与key对应的关联值。如果没有,那么此函数会分配一块内存并通过pthread_setspecific()保存指向该内存块的指针。
使用线程特有数据API
TODO.
参考链接:https://www.jianshu.com/p/61c2d33877f4
线程:线程取消
在通常情况下,程序中的多个线程会并发执行,每个线程各司其职,直至其决意退出,随即会调用函数pthread_exit()或者从线程启动函数中返回。
有时候,需要将一个线程取消(cancel)。亦即,向线程发送一个请求,要求其立即退出。比如,一组线程正在执行一个运算,一旦某个线程检测到错误发生,需要其他线程退出,取消线程的功能这时就派上用场。还有一种情况,一个由图形用户界面(GUI)驱动的应用程序可能会提供一个“取消”按钮,以便用户可以终止后台某一线程正在执行的任务。这种情况下,主线程(控制图形用户界面)需要请求后台线程退出。
取消一个线程
函数pthread_cancel()向由thread指定的线程发送一个取消请求。
#include<pthread.h>
int pthread_cancel(pthread_t thread)
发出取消请求后,函数pthread_cancel()当即返回,不会等待目标线程的退出。
准确地说,目标线程会发生什么?何时发生?这些都取决于线程取消状态(state)和类型(type)。
取消状态及类型
函数pthread_setcancelstate()和pthread_setcanceltype()会设定标志,允许线程对取消请求的响应过程加以控制。
#include<pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);
函数pthread_setcancelstate()会将调用线程的取消性状态置为参数state所给定的值。该参数的值如下。
PTHREAD_CANCEL_DISABLE
线程不可取消。如果此类线程收到取消请求,则会将请求挂起,直至将线程的取消状态置为启用。
PTHREAD_CANCEL_ENABLE
线程可以取消。这是新建线程取消性状态的默认值。
线程的前一取消性状态将返回至参数oldstate所指向的位置。
如果对前一状态没有兴趣,Linux允许将oldstate置为NULL。在很多其他的系统实现中,情况也是如此。不过,SUSv3并没有规范这一特性,所以要保证应用的可移植性,就不能依赖这一特性。应该总是为oldstate设置一个非NULL的值。
如果线程执行的代码片段需要不间断地一气呵成,那么临时屏闭线程的取消性状态(PTHREAD_CANCEL_DISABLE)就变得很有必要。
如果线程的取消性状态为“启用”(PTHREAD_CANCEL_ENABLE),那么对取消请求的处理则取决于线程的取消性类型,该类型可以通过调用函数pthread_setcanceltype()时的参数type给定。参数type有如下值:
PTHREAD_CANCEL_ASYNCHRONOUS
可能会在任何时点(也许是立即取消,但不一定)取消线程。异步取消的应用场景很少,将延后至32.6节再做讨论。
PTHREAD_CANCEL_DEFERED
取消请求保持挂起状态,直至到达取消点(cancellation point,见下节)。这也是新建线程的缺省类型。后续各节将介绍延迟取消(deferred cancelability)的更多细节。
线程原有的取消类型将返回至参数oldtype所指向的位置。
与函数pthread_setcancelstate()的参数oldstate类似,如果不关心原有取消类型,许多系统实现(包括Linux)允许将oldtype置为NULL。同样,SUSv3也没有规范这一行为,所以需要保障可移植性的应用不应使用这一特性,应该总是为oldtype设置一个非NULL值。
当某线程调用fork()时,子进程会继承调用线程的取消性类型及状态。而当某线程调用exec()时,会将新程序主线程的取消性类型及状态分别重置为PTHREAD_CANCEL_NABLE和PTHREAD_CANCEL_DEFERRED。
取消点
若将线程的取消性状态和类型分别置为启用和延迟,仅当线程抵达某个取消点(cancellation point)时,取消请求才会起作用。取消点即是对由实现定义的一组函数之一加以调用。
SUSv3规定,实现若提供了下表所列的函数,则这些函数必须是取消点。其中的大部分函数都有能力将线程无限期地堵塞起来。
函数 | 函数 | 函数 |
---|---|---|
accept() | nanosleep() | sem_timedwait() |
aio_suspend() | open() | sem_wait() |
clock_nanosleep() | pause() | send() |
close() | poll() | sendmsg() |
connect() | pread() | sendto() |
creat() | pselect() | sigpause() |
fcntl(F_SETLKW) | pthread_cond_timedwait() | sigsuspend() |
fsync() | pthread_cond_wait() | sigtimedwait() |
fdatasync() | pthread_join() | sigwait() |
getmsg() | pthread_testcancel() | sigwaitinfo() |
getpmsg() | putmsg() | sleep() |
lockf(F_LOCK) | putpmsg() | system() |
mq_receive() | pwrite() | tcdrain() |
mq_send() | read() | usleep() |
mq_timedreceive() | readv() | wait() |
mq_timedsend() | recv() | waitid() |
msgrcv() | recvfrom() | waitpid() |
msgsnd() | recvmsg() | write() |
msync() | select() | writev() |
除上表所列函数之外,SUSv3还指定了大量函数,系统实现可以将其定义为取消点。其中包括stdio函数、dlopen API、syslog API、nftw()、popen()、semop()、unlink(),以及从诸如 utmp 之类的系统文件中获取信息的各种函数。可移植应用程序必须正确处理这一情况:线程在调用这些函数时有可能遭到取消。
SUSv3规定,除了上述两组必须或可能是可取消点的函数之外,不得将标准中的任何其他函数视为取消点(亦即,调用这些函数不会招致线程取消,可移植程序无需加以处理)。
SUSv4在必须的可取消点函数列表中增加了openat(),并移除了函数sigpause()(将其移至“可能的”取消点函数列表中)和函数usleep()(已从标准中删除)。
系统实现可随意将标准并未规范的其他函数标记为取消点。任何可能造成堵塞的函数(有可能是因为需要访问文件)都是取消点的理想候选对象。出于这一理由,glibc将其中的许多非标准函数标记为取消点。
线程一旦收到取消请求,且启用了取消性状态并将类型置为延迟,则其会在下次抵达取消点时终止。如果该线程尚未分离(not detached),那么为防止其变为僵尸线程,必须由其他线程对其进行连接(join)。连接之后,返回至函数pthread_join()中第二个参数的将是一个特殊值:PTHREAD_CANCELED。
线程可取消性的检测
由main()创建的线程会执行到属于取消点的函数(sleep()属于取消点,printf()可能也是),因而会接受取消请求。不过,假设线程执行的是一个不含取消点的循环(计算密集型[compute-bound]循环),这时,线程永远也不会响应取消请求。
函数pthread_testcancel()的目的很简单,就是产生一个取消点。线程如果已有处于挂起状态的取消请求,那么只要调用该函数,线程就会随之终止。
#include<pthread.h>
void pthread_testcancel(void);
当线程执行的代码未包含取消点时,可以周期性地调用pthread_testcancel(),以确保对其他线程向其发送的取消请求做出及时响应。
清理函数(cleanup handler)
一旦有处于挂起状态的取消请求,线程在执行到取消点时如果只是草草收场,这会将共享变量以及Pthreads对象(例如互斥量)置于一种不一致状态,可能导致进程中其他线程产生错误结果、死锁,甚至造成程序崩溃。为规避这一问题,线程可以设置一个或多个清理函数,当线程遭取消时会自动运行这些函数,在线程终止之前可执行诸如修改全局变量,解锁互斥量等动作。
每个线程都可以拥有一个清理函数栈。当线程遭取消时,会沿该栈自顶向下依次执行清理函数,首先会执行最近设置的函数,接着是次新的函数,以此类推。当执行完所有清理函数后,线程终止。
函数 pthread_cleanup_push()和 pthread_cleanup_pop()分别负责向调用线程的清理函数栈添加和移除清理函数。
#include <pthread.h>
void pthread_cleanup_push(void (*rtn)(void *),void *arg);
void pthread_cleanup_pop(int execute);
pthread_cleanup_push()会将参数routine所含的函数地址添加到调用线程的清理函数栈顶。参数routine是一个函数指针,格式如下:
void routine(void *arg)
{}
/* 执行清理代码 */
}
执行pthread_cleanup_push()时给定的 arg 值,会作为调用清理函数时的参数。其参数类型为void*,如果强制装换使用得当,那么通过该参数可以传入各种类型的数据。
通常,线程如在执行一段特殊代码时遭到取消,才需要执行清理动作。如果线程顺利执行完这段代码而未遭取消,那么就不再需要清理。所以,每个对pthread_cleanup_push()的调用都会伴随着对 pthread_cleanup_pop()的调用。此函数从清理函数栈中移除最顶层的函数。如果参数 execute 非零,那么无论如何都会执行清理函数。在函数未遭取消而又希望执行清理动作的情况下,这会非常方便。
尽管这里把pthread_cleanup_push()和pthread_cleanup_pop()描述为函数,SUSv3却允许将它们实现为宏(macro),可展开为分别由{和}所包裹的语句序列。并非所有的 UNIX都这样做,不过包括Linux在内的很多系统都是使用宏来实现的。这意味着,pthread_cleanup_push()和与其配对的pthread_cleanup_pop()属于同一个语法块,必须一一对应。(一旦以此方式来实现pthread_cleanup_push()和pthread_cleanup_pop(),在对两者的调用间所声明的变量,其作用域将受限于这一语法块。)例如,以下代码就不正确:
pthread_cleanup_push(func,arg);
...
if(cond){
pthread_cleanup_pop(0);
}
为便于编码,若线程因调用pthread_exit()而终止,则也会自动执行尚未从清理函数栈中弹出(pop)的清理函数。线程正常返回(return)时不会执行清理函数。
异步取消
如果设定线程为可异步取消时(取消性类型为PTHREAD_CANCEL_ASYNCHRONOUS),可以在任何时点将其取消(亦即,执行任何机器指令时),取消动作不会拖延到下一个取消点才执行。
异步取消的问题在于,尽管清理函数依然会得以执行,但处理函数却无从得知线程的具体状态。程序清单32-2采用了延时取消类型,只有在执行到pthread_cond_wait()这一唯一的取消点时,线程才会遭到取消。此时可知,已将buf初始化为指向新分配的内存块,并且锁定了互斥量mtx。不过,要是采用异步取消,就可以在任意点取消线程(例如,调用malloc()之前,调用malloc()与锁定互斥量之间,或者锁定互斥量之后)。清理函数无法知道将在哪里发生取消动作,或者准确地来说,清理函数不清楚需要执行哪些清理步骤。此外,线程也很可能在对malloc()的调用期间被取消,这极有可能造成后续的混乱(见7.1.3节)。
作为一般性原则,可异步取消的线程不应该分配任何资源,也不能获取互斥量或锁。这导致大量库函数无法使用,其中就包括Pthreads函数的大部分。(SUSv3中有3处例外pthread_cancel()、pthread_setcancelstate()以及pthread_setcanceltype(),规范明确要求将它们实现为“异步取消安全(async-cancel-safe)”,亦即,实现必须确保在可异步取消的线程中可以安全调用它们。)换言之,异步取消功能鲜有应用场景,其中之一就是:取消在执行计算密集型循环的线程。
线程:更多细节
线程栈
创建线程时,每个线程都有一个属于自己的线程栈,且大小固定。在Linux/x86-32架构上,除主线程外的所有线程,其栈的缺省大小均为2MB。(在一些64位架构下,默认尺寸要大一些,例如,IA-64有32MB。)为了应对栈的增长(参考图29-1),主线程栈的空间要大出许多。
偶尔,也需要改变线程栈的大小。在通过线程属性对象创建线程时,调用函数pthread_attr_setstacksize()所设置的线程属性决定了线程栈的大小。而使用与之相关的另一函数pthread_attr_setstack(),可以同时控制线程栈的大小和位置,不过设置栈的地址将降低程序的可移植性。手册页(manual page)提供了对这些函数的具体说明。
更大的线程栈可以容纳大型的自动变量或者深度的嵌套函数调用(也许是递归调用),这是改变每个线程栈大小的原因之一。而另一方面,应用程序可能希望减小每个线程栈,以便进程可以创建更多的线程。例如,在 x86-32 系统中,用户(模式)可访问的虚拟地址空间是3GB,而2MB的缺省栈大小则意味着最多只能创建 1500 个线程。(更为准确的最大值还视乎文本段、数据段、共享函数库等对虚拟内存的消耗量。)特定架构的系统上,可采用的线程栈大小最小值可以通过调用sysconf(_SC_THREAD_STACK_MION)来确定。在Linux/x86-32上的NPTL实现中,该调用返回16384。
在NPTL线程实现中,如果对线程栈尺寸资源限制(RLIMIT_STACK)的设置不同于unlimited,那么创建线程时会以其作为默认值。对该限制的设置必须在运行程序之前,通常通过执行shell内建命令ulimit–s完成(在C shell下命令为limit stacksize)。在主程序中调用setrlimit()来设置限制的办法可能行不通,因为NPTL在调用main()之前的运行时初始化期间就已经确定了默认的栈大小。
线程和信号
UNIX信号模型是基于UNIX进程模型而设计的,问世比Pthreads要早几十年。自然而然,信号与线程模型之间存在一些明显的冲突。主要是因为,一方面,针对单线程进程要保持传统的信号语义(Pthreads不应改变传统进程的信号语义),与此同时,又需要开发出适用于多线程进程环境的新信号模型。
信号与线程模型之间的差异意味着,将二者结合使用,将会非常复杂,应尽可能加以避免。尽管如此,有的时候还是必须在多线程程序中处理信号问题。本节将讨论信号与线程间的交互,并描述在多线程程序中处理信号的各种有效函数。
UNIX信号模型如何映射到线程中
要了解UNIX信号如何映射到Pthreads模型,就需要了解,信号模型的哪些方面属于进程层面(由进程中的所有线程所共享),哪些方面是属于进程中的单个线程层面。如下是对其关键点的汇总。
TODO.
进程组、会话和作业控制
进程组和会话在进程之间形成了一种两级层次关系:进程组是一组相关进程的集合,会话是一组相关进程组的集合。
进程组和会话是为支持shell作业控制而定义的抽象概念,用户通过shell能够交互式地在前台或后台运行命令。术语“作业”通常与术语“进程组”作为同义词来看待。
概述
进程组由一个或多个共享同一进程组标识符(PGID)的进程组成。进程组ID是一个数字,其类型与进程ID一样(pid_t)。一个进程组拥有一个进程组首进程,该进程是创建该组的进程,其进程ID为该进程组的ID,新进程会继承其父进程所属的进程组ID。
进程组拥有一个生命周期,其开始时间为首进程创建组的时刻,结束时间为最后一个成员进程退出组的时刻。一个进程可能会因为终止而退出进程组,也可能会因为加入了另外一个进程组而退出进程组。进程组首进程无需是最后一个离开进程组的成员。
会话是一组进程组的集合。进程的会话成员关系是由其会话标识符(SID)确定的,会话标识符与进程组ID一样,是一个类型为pid_t的数字。会话首进程是创建该新会话的进程,其进程ID会成为会话 ID。新进程会继承其父进程的会话ID。
一个会话中的所有进程共享单个控制终端。控制终端会在会话首进程首次打开一个终端设备时被建立。一个终端最多可能会成为一个会话的控制终端。
在任一时刻,会话中的其中一个进程组会成为终端的前台进程组,其他进程组会成为后台进程组。只有前台进程组中的进程才能从控制终端中读取输入。当用户在控制终端中输入其中一个信号生成终端字符之后,该信号会被发送到前台进程组中的所有成员。这些字符包括生成SIGINT的中断字符(通常是Control-C)、生成SIGQUIT的退出字符(通常是Control-\)、生成SIGSTP的挂起字符(通常是Control-Z)。
当到控制终端的连接建立起来(即打开)之后,会话首进程会成为该终端的控制进程。成为控制进程的主要标志是当断开与终端之间的连接时内核会向该进程发送一个SIGHUP信号。
通过检查Linux特有的/proc/PID/stat文件,就能确定任意进程的进程组ID和会话ID。此外,还能确定进程的控制终端的设备ID(一个十进制数字,包含主ID和辅ID)和控制该终端的控制进程的进程ID。更多细节信息请参考proc(5)手册。
会话和进程组的主要用途是用于shell作业控制。读者通过一个具体的例子就能够弄清楚这些概念了。如对于交互式登录来讲,控制终端是用户登录的途径。登录shell是会话首进程和终端的控制进程,也是其自身进程组的唯一成员。从shell中发出的每个命令或通过管道连接的一组命令都会导致一个或多个进程的创建,并且shell会把所有这些进程都放在一个新进程组中。(这些进程在一开始是其进程组中的唯一成员,它们创建的所有子进程会成为该组中的成员。)当命令或以管道连接的一组命令以&符号结束时会在后台进程组中运行这些命令,否则就会在前台进程组中运行这些命令。在登录会话中创建的所有进程都会成为该会话的一部分。
在窗口环境中,控制终端是一个伪终端。每个终端窗口都有一个独立的会话,窗口的启动shell是会话首进程和终端的控制进程。
终端(terminal)、tty、shell、控制台(console)、bash之间的区别与联系
1、终端(terminal)
终端(termimal)= tty(Teletypewriter, 电传打印机),作用是提供一个命令的输入输出环境,在linux下使用组合键ctrl+alt+T打开的就是终端,可以认为terminal和tty是同义词。
2、shell
shell是一个命令行解释器,是linux内核的一个外壳,负责外界与linux内核的交互。shell接收用户或者其他应用程序的命令, 然后将这些命令转化成内核能理解的语言并传给内核, 内核执行命令完成后将结果返回给用户或者应用程序。当你打开一个terminal时,操作系统会将terminal和shell关联起来,当我们在terminal中输入命令后,shell就负责解释命令。
3、console
在计算机发展的早期,计算机的外表上通常会存在一个面板,面板包含很多按钮和指示灯,可以通过面板来对计算机进行底层的管理,也可以通过指示灯来得知计算机的运行状态,这个面板就叫console。在现代计算机上,在电脑开机时(比如ubuntu)屏幕上会打印出一些日志信息,但在系统启动完成之前,terminal不能连接到主机上,所以为了记录主机的重要日志(比如开关机日志,重要应用程序的日志),系统中就多了一个名为console的设备,这些日志信息就是显示在console上。一台电脑有且只有一个console,但可以有多个terminal。举个例子,电视机上的某个区域一般都会有一些按钮,比如开机,调音量等,这个区域就可以当做console,且这个区域在电视上只有一个,遥控器就可以类比成terminal,terminal可以有多个。
4、bash
linux系统上可以包含多种不同的shell(可以使用命令 cat /etc/shells 查看),比较常见的有Bourne shell (sh)、C shell (csh) 和 Korn shell (ksh),三种shell 都有它们的优点和缺点。Bourne shell 的作者是 Steven Bourne,它是 UNIX 最初使用的shell 并且在每种 UNIX 上都可以使用。bash的全称叫做Bourne Again shell,从名字上可以看出bash是Bourne shell的扩展,bash 与 Bourne shell 完全向后兼容,并且在 Bourne shell 的基础上增加和增强了很多特性,如命令补全、命令编辑和命令历史表等功能,它还包含了很多 C shell 和 Korn shell 中的优点,有灵活和强大的编程接口,同时又有很友好的用户界面。总而言之,bash是shell的一种,是增强的shell。
进程组
每个进程都拥有一个以数字表示的进程组ID,表示该进程所属的进程组。新进程会继承其父进程的进程组ID,使用getpgrp()能够获取一个进程的进程组ID。
#include<unistd.h>
pid_t getpgrp(void);
如果getpgrp()的返回值与调用进程的进程 ID 匹配的话就说明该调用进程是其进程组的首进程。
setpgid()系统调用将进程ID为pid的进程的进程组 ID 修改为pgid。
#include<unistd.h>
int setpgid(pid_t pid,pid_t pgid);
如果将pid的值设置为0,那么调用进程的进程组ID就会被改变。如果将pgid的值设置为0,那么 ID 为pid的进程的进程组 ID 会被设置成pid的值。因此,下面的setpgid()调用是等价的。
setpgid(0,0);
setpgid(getpid(),0);
setpgid(getpid(),getpid());
如果pid和pgid参数指定了同一个进程(即pgid是0或者与 ID 为pid的进程的进程ID匹配),那么就会创建一个新进程组,并且指定的进程会成为这个新组的首进程(即进程的进程组 ID 与进程 ID 是一样的)。如果两个参数的值不同(即pgid不是0或者与 ID 为pid的进程的进程 ID 不匹配),那么setpgid()调用会将一个进程从一个进程组中移到另一个进程组中。
通常调用setpgid()(以及setsid())函数的是shell和login(1)。一个程序在使自己变成daemon的过程中也会调用setsid()。
在调用setpgid()时存在以下限制。
- pid参数可以仅指定调用进程或其中一个子进程。违反这条规则会导致ESRCH错误。
- 在组之间移动进程时,调用进程、由pid指定的进程(可能是另外一个进程,也可能就是调用进程)以及目标进程组必须要属于同一个会话。违反这条规则会导致EPERM错误。
- pid参数所指定的进程不能是会话首进程。违反这条规则会导致EPERM错误。
- 一个进程在其子进程已经执行exec()后就无法修改该子进程的进程组 ID 了。违反这条规则会导致EACCES错误。之所以会有这条约束条件的原因是在一个进程开始执行之后再修改其进程组 ID 的话会使程序变得混乱。
在作业控制shell中使用setpgid()
一个进程在其子进程已经执行exec()之后就无法修改该子进程的进程组ID的约束条件会影响到基于shell的作业控制程序设计,即需要满足下列条件。
- 一个任务(即一个命令或一组以管道符连接的命令)中的所有进程必须被放置在一个进程组中。
- 这一步允许shell使用killpg()(或使用负的pid值来调用kill())来同时向进程组中的所有成员发送作业控制信号。一般来讲,这一步需要在发送任意作业控制信号前完成。
- 每个子进程在执行程序之前必须要被分配到进程组中,因为程序本身是不清楚如何操作进程组ID的。
对于任务中的各个进程来讲,父进程和子进程都可以使用setpgid()来修改子进程的进程组ID。但是,由于在父进程执行fork()之后父进程与子进程之间的调度顺序是无法确定的,因此无法依靠父进程在子进程执行exec()之前来改变子进程的进程组ID,同样也无法依靠子进程在父进程向其发送任意作业控制信号之前修改其进程组ID。(依赖这些行为中的任意一个行为都会导致竞争条件。)因此,在编写作业控制shell程序时需要让父进程和子进程在fork()调用之后立即调用setpgid()来将子进程的进程组ID设置为同样的值,并且父进程需要忽略在setpgid()调用中出现的所有EACCES错误。
获取和修改进程组ID的其他(过时的)接口
这里需要解释一下为何getpgrp()和setpgid()两个系统调用名称中的后缀不同。
在一开始,4.2BSD提供了一个getprgp(pid)系统调用来返回进程ID为pid的进程的进程组ID。在实践中,pid几乎总是用来表示调用进程。结果,POSIX委员会认为这个系统调用过于复杂了,因此他们采纳了System V getpgrp()系统调用,这个系统调用不接收任何参数并返回调用进程的进程组ID。
为了修改进程组ID,4.2BSD提供了setpgrp(pid,pgid)系统调用,它与setpgid()的行为是相似的。这两个系统调用之间最主要的差别在于BSD setpgrp()能够用来将进程组ID设置为任意值。(前面曾经提及过不能使用setpgid()将一个进程迁移至其他会话中的进程组。)这会引起一些安全问题,但在实现任务控制程序时也更加灵活。结果,POSIX委员会决定给这个函数增加额外的限制条件并将其命名为setpgid()。
更复杂的事情是SUSv3指定了一个getpgid(pid)系统调用,它与老式的BSD getpgrp()的功能是一样的。此外,它还定义了一个从System V演化而来的setpgrp(),它不接受任何参数,与setpgid(0, 0)调用几乎是等价的。
尽管对于实现shell作业控制来讲,利用前面介绍的setpgid()和getpgrp()系统调用已经足够了。但与其他大多数UNIX实现一样,Linux也提供了getpgid(pid)和setpgrp(void)。为了向后兼容,很多从BSD演化而来的实现仍然提供了setprgp(pid, pgid),它与setpgid(pid, pgid)是一样的。
在编译程序时如果显式地定义_BSD_SOURCE特性测试宏的话,glibc会使用从BSD演化而来的setpgrp()和getpgrp()来取代默认版本。
会话
会话是一组进程组集合。一个进程的会话成员关系是由其会话ID来定义的,会话ID是一个数字。新进程会继承其父进程的会话ID。getsid()系统调用会返回pid指定的进程的会话ID。
#define _XOPEN_SOURCE 500
#include<unistd.h>
pid_t getsid(pid_t pid);
如果pid参数的值为0,那么getsid()会返回调用进程的会话ID。
如果调用进程不是进程组首进程,那么setsid()会创建一个新会话。
#include<unistd.h>
pid_t setsid(void);
setsid()系统调用会按照下列步骤创建一个新会话。
- 调用进程成为新会话的首进程和该会话中新进程组的首进程。调用进程的进程组ID和会话ID会被设置成该进程的进程ID。
- 调用进程没有控制终端。所有之前到控制终端的连接都会被断开。
如果调用进程是一个进程组首进程,那么setsid()调用会报出EPERM错误。避免这个错误发生的最简单的方式是执行一个fork()并让父进程终止以及让子进程调用setsid()。由于子进程会继承其父进程的进程组 ID 并接收属于自己的唯一的进程 ID,因此它无法成为进程组首进程。
约束进程组首进程对setsid()的调用是有必要的。因为如果没有这个约束的话,进程组组长就能够将其自身迁移至另一个(新的)会话中了,而该进程组的其他成员则仍然位于原来的会话中。(不会创建一个新进程组,因为根据定义,进程组首进程的进程组ID已经与其进程ID一样了。)这会破坏会话和进程组之间严格的两级层次,因此一个进程组的所有成员必须属于同一个会话。
当使用fork()创建一个新进程时,内核会确保它拥有一个唯一的进程ID,并且该进程ID不会与任意已有进程的进程组ID和会话ID相同。这样,即使进程组或会话首进程退出之后,新进程也无法复用首进程的进程ID,从而也无法成为既有会话和进程组的首进程。
程序清单演示了使用setsid()来创建一个新会话。为了检查该进程已经不再拥有控制终端了,这个程序尝试打开一个特殊文件/dev/tty(下一节将予以介绍)。当运行这个程序时会看到下面的结果:
#if ! defined(_XOPEN_SOURCE) || _XOPEN_SOURCE < 500
#define _XOPEN_SOURCE 500
#endif
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include<unistd.h>
int
main(int argc, char *argv[])
{
if (fork() != 0) /* Exit if parent, or on error */
printf("EXIT_SUCCESS\n");
if (setsid() == -1)
printf("setsid");
printf("PID=%ld, PGID=%ld, SID=%ld\n", (long) getpid(),
(long) getpgrp(), (long) getsid(0));
/* Following should fail, since we don't have a controlling terminal */
if (open("/dev/tty", O_RDWR) == -1)
printf("Error:open /dev/tty\n");
return 0;
}
EXIT_SUCCESS
setsidPID=23910, PGID=23910, SID=4681
PID=23911, PGID=23911, SID=23911
Error:open /dev/tty
从输出中可以看出,进程成功地将其自身迁移至了新会话中的一个新进程组中。由于这个会话没有控制终端,因此open()调用会失败。(从上面程序输出的倒数第二行中可以看出,hell提示符与程序输出混杂在一起了,因为shell注意到父进程在fork()调用之后就退出了,因此在子进程结束之前就输出了下一个提示符。)
控制终端和控制进程
一个会话中的所有进程可能会拥有一个(单个)控制终端。会话在被创建出来的时候是没有控制终端的,当会话首进程首次打开一个还没有成为某个会话的控制终端的终端时会建立控制终端,除非在调用open()时指定O_NOCTTY标记。一个终端至多只能成为一个会话的控制终端。
SUSv3定义了函数tcgetsid(int fd)(在<termios.h>头文件中进行定义),它返回与由fd指定的控制终端相关联的会话的ID。glibc提供了这个函数(它是使用ioctl() TIOCGSID操作实现的)。
控制终端会被由fork()创建的子进程继承并且在exec()调用中得到保持。
当会话首进程打开了一个控制终端之后它同时也成为了该终端的控制进程。在发生终端断开之后,内核会向控制进程发送一个SIGHUP信号来通知这一事件的发生。
如果一个进程拥有一个控制终端,那么打开特殊文件/dev/tty就能够获取该终端的文件描述符。这对于一个程序在标准输入和输出被重定向之后需要确保自己确实在与控制终端进行通信是很有用的。如果进程没有控制终端,那么在打开/dev/tty时会报出ENXIO的错误。
删除进程与控制终端之间的关联关系
使用ioctl(fd, TIOCNOTTY)操作能够删除进程与文件描述符fd指定的控制终端之间的关联关系。在调用这个函数之后再试图打开/dev/tty文件的话就会失败。(尽管SUSv3没有指定这个操作,但大多数UNIX实现都支持TIOCNOTTY操作。)
如果调用进程是终端的控制进程,那么在控制进程终止时会发生下列事情。
1. 会话中的所有进程将会失去与控制终端之间的关联关系。
2. 控制终端失去了与该会话之间的关联关系,因此另一个会话首进程就能够获取该终端以成为控制进程。
3. 内核会向前台进程组的所有成员发送一个SIGHUP信号(和一个SIGCONT信号)来通知它们控制终端的丢失。
在BSD上建立一个控制终端
SUSv3并不支持一个会话获取未指定的控制终端,在打开终端时仅指定O_NOCTTY标记的话只能确保该终端不会成为会话的控制终端。上面描述的Linux语义源自System V系统。
在BSD系统上,在会话首进程中打开一个终端不会导致该终端成为控制终端,不管是否指定了O_NOCTTY标记。相反,会话首进程需要使用ioctl() TIOCSCTTY操作来显式地将文件描述符fd指定的终端建立为控制终端。
if(ioctl(fd,TIOCSCTTY)==-1)
printf("ioctl");
只有在会话没有控制终端时才能执行这个操作。
Linux系统上也有TIOCSCTTY操作,但在其他(非BSD)实现中用得并不多。
获取表示控制终端的路径名:ctermid()
ctermid()函数返回表示控制终端的路径名。
#include<stdio.h>
char *ctermid(char *ttyname);
ctermid()函数以两种不同的方式返回控制终端的路径名:通过函数结果和通过ttyname指向的缓冲区。
如果ttyname不为NULL,那么它是一个大小至少为L_ctermid字节的缓冲区,并且路径名会被复制进这个数组中。这里函数的返回值也是一个指向该缓冲区的指针。如果ttyname为NULL,那么ctermid()返回一个指向静态分配的缓冲区的指针,缓冲区中包含了路径名。当ttyname为NULL时,ctermid()是不可重入的。
在Linux和其他UNIX实现中,ctermid()通常会生成字符串/dev/tty。引入这个函数的目的是为了能更加容易地将程序移植到非UNIX系统上。
前台和后台进程组
控制终端保留了前台进程组的概念。在一个会话中,在同一时刻只有一个进程能成为前台进程,会话中的其他所有进程都是后台进程组。前台进程组是唯一能够自由地读取和写入控制终端的进程组。当在控制终端中输入其中一个信号生成终端字符之后,终端驱动器会将相应的信号发送给前台进程组的成员。
从理论上来讲,可能会出现一个会话没有前台进程组的情况。如当前台进程组中的所有进程都终止并且没有其他进程注意到这个事实而将自己移动到前台时就会出现这种情况。但在实践中这种情况是比较少见的。通常shell进程会监控前台进程组的状态,当它注意到前台进程组结束之后(通过wait())会将自己移动到前台。
tcgetpgrp()和tcsetpgrp()函数分别获取和修改一个终端的进程组。这些函数主要供任务控制shell使用。
#include<unistd.h>
pid_t tcgetpgrp(int fd);
int tcsetpgrp(int fd,pid_t pgid);
tcgetpgrp()函数返回文件描述符fd所指定的终端的前台进程组的进程组ID,该终端必须是调用进程的控制终端。
如果这个终端没有前台进程组,那么tcgetpgrp()返回一个大于1并且与所有既有进程组ID都不匹配的值。(SUSv3规定了这种行为。)
tcsetpgrp()函数修改一个终端的前台进程组。如果调用进程拥有一个控制终端,那么文件描述符fd引用的就是那个终端,接着tcsetpgrp()会将终端的前台进程组设置为pgid参数指定的进程组,该参数必须与调用进程所属的会话中的一个进程的进程组ID匹配。
tcgetpgrp() 和 tcsetpgrp()在SUSv3中都被标准化了。在Linux上,与很多其他UNIX实现一样,这些函数是通过两个非标准的ioctl()操作来实现的,即TIOCGPGRP和TIOCSPGRP。
SIGHUP信号
当一个控制进程失去其终端连接之后,内核会向其发送一个SIGHUP信号来通知它这一事实。(还会发送一个SIGCONT信号以确保当该进程之前被一个信号停止时重新开始该进程。)一般来讲,这种情况可能会在下面两个场景中出现。
- 当终端驱动器检测到连接断开后,表明调制解调器或终端行上信号的丢失。
- 当工作站上的终端窗口被关闭时。发生这种情况是因为最近打开的与终端窗口关联的伪终端的主侧的文件描述符被关闭了。
SIGHUP信号的默认处理方式是终止进程。如果控制进程处理了或忽略了这个信号,那么后续尝试从终端中读取数据的请求就会返回文件结束的错误。
SUSv3声称如果终端断开发生的同时还满足调用read()时抛出EIO错误的条件的话,那么调用read()既有可能返回文件结束,也有可能返回EIO错误。可移植的程序必须要处理好这两种情况。
向控制进程发送SIGHUP信号会引起一种链式反应,从而导致将SIGHUP信号发送给很多其他进程。这个过程可能会以下列两种方式发生。
- 控制进程通常是一个shell。shell建立了一个SIGHUP信号的处理器,这样在进程终止之前,它能够将SIGHUP信号发送给由它所创建的各个任务。在默认情况下,这个信号会终止那些任务,但如果它们捕获了这个信号,就能知道shell进程已经终止了。
- 在终止终端的控制进程时,内核会解除会话中所有进程与该控制终端之间的关联关系以及控制终端与该会话的关联关系(因此另一个会话首进程可以请求该终端成为控制终端了),并且通过向该终端的前台进程组的成员发送SIGHUP信号来通知它们控制终端的丢失。
在shell中处理SIGHUP信号
在登录会话中,shell通常是终端的控制进程。大多数shell程序在交互式运行时会为SIGHUP信号建立一个处理器。这个处理器会终止shell,但在终止之前会向由shell创建的各个进程组(包括前台和后台进程组)发送一个SIGHUP信号。(在SIGHUP信号之后可能会发送一个SIGCONT信号,这依赖于shell本身以及任务当前是否处于停止状态。)至于这些组中的进程如何响应SIGHUP信号则需要根据应用程序的具体需求,如果不采取特殊的动作,那么默认情况下将会终止进程。
一些任务控制shell在正常退出(如登出或在shell窗口中接下Control-D)时也会发送SIGHUP信号来停止后台任务。bash和Korn shell都采取了这种处理方式(在首次登出尝试时打印出一条消息之后)。
nohup(1)命令可以用来使一个命令对SIGHUP信号免疫——即执行命令时将SIGHUP信号的处理设置为SIG_IGN。bash内置的命令disown提供了类似的功能,它从shell的任务列表中删除一个任务,这样在shell终止时就不会向该任务发送SIGHUP信号了。
SIGHUP和控制进程的终止
如果因为终端断开引起的向控制进程发送的SIGHUP信号会导致控制进程终止,那么SIGHUP信号会被发送给终端的前台进程组中的所有成员。这个行为是控制进程终止的结果,而不是专门与SIGHUP信号关联的行为。如果控制进程出于任何原因终止,那么前台进程组就会收到SIGHUP信号。
在Linux上,SIGHUP信号后面会跟着一个SIGCONT信号以确保在进程组之前被一个信号停止的情况下恢复该进程组。但SUSv3并没有指定这种行为,并且在这种情况下大多数其他UNIX实现不会发送SIGCONT信号。
作业控制
作业控制是在1980年左右由BSD系统上的C shell首次推出的特性。作业控制允许一个shell用户同时执行多个命令(作业),其中一个命令在前台运行,其余的命令在后台运行。作业可以被停止和恢复以及在前后台之间移动.
在初始的POSIX.1标准中,对作业的支持是可选的。后面的UNIX标准使这个功能成为了必备功能。
在基于字符的哑终端盛行的年代(物理终端设备只能显示ASCII字符),很多shell用户都知道如何使用shell作业控制命令。在运行X Window System的位图显示器出现之后,熟悉shell作业控制的人就越来越少了,但作业控制仍然是一项非常有用的特性。使用作业控制管理多个同时执行的命令比在几个窗口之间来回切换更快速和简单。
在shell中使用作业控制
当输入的命令以&符号结束时,该命令会作为后台任务运行,如下面的示例所示。
$ grep -r SIGHUP /usr/src/linux >x &
[1] 18932
$ sleep 60 &
[2] 18934
shell会为后台的每个进程赋一个唯一的作业号。当作业在后台运行之后以及在使用各种作业控制命令操作或监控作业时作业号会显示在方括号中。作业号后面的数字是执行这个命令的进程的进程ID或管道中最后一个进程的进程ID。在后面几个段落中介绍的命令中会使用%num来引用作业,其中num是shell赋给作业的作业号。
在很多情况下是可以省略%num的,当省略%num时默认指当前作业。当前作业是在前台最新被停止的作业(使用下面介绍的挂起字符)或者如果没有这样的作业的话,最新作业是在后台启动的任务。(不同shell确定哪个后台作业为当前作业的细节方面稍微有些不同。)另外,%%和%+符号指的是当前作业,%−符号指的是上一个当前作业。在jobs命令的输出中,当前的和上一个当前作业分别用加号(+)和减号(−)标记.
jobs是shell内置的一个命令,它会列出所有后台作业。
$ jobs
[1]- Running grep -r SIGHUP /usr/src/linux >x &
[2]+ Running sleep 60 &
在这个时刻,shell是终端的前台进程。由于仅有一个前台进程能够从控制终端读取输入和接收终端生成的信号,因此有时候需要将后台作业移动到前台。这是通过fg这个shell内置命令来完成的。
$ fg %1
grep -r SIGHUP /usr/src/linux >x
从上面的示例中可以看出,当在前后台之间移动作业时shell会重新打印出该作业的命令行。读者通过阅读下面的内容就会发现,当作业在后台的状态发生变化时,shell也会重新打印该作业的命令行。
当作业在前台运行时可以使用终端挂起字符(通常是Control-Z)来挂起作业,它会向终端的前台进程组发送一个SIGTSTP信号。
Type Control-Z
[1]+ Stopped grep -r SIGHUP /usr/src/linux >x
在按下Control-Z之后,shell会打印出在后台被停止的命令。如果需要的话,可以使用fg命令在前台恢复这个作业或使用bg命令在后台恢复这个命令。不管使用哪个命令恢复作业,shell都会通过向任务发送一个SIGCONT信号来恢复被停止的作业。
$ bg %1
[1]+ grep -r SIGHUP /usr/src/linux >x &
通过向后台作业发送一个SIGSTOP信号能够停止该后台作业。
$ kill -STOP %1
[1]+ Stopped grep -r SIGHUP /usr/src/linux >x &
Korn和C shell提供了一个命令stop作为kill-stop快捷方式。
当后台作业最后执行结束之后,shell会在打印下一个shell提示符之前先打印一条消息。
$ jobs
[2]+ Done sleep 60
只有前台作业中的进程才能够从控制终端中读取输入。这个限制条件避免了多个作业竞争读取终端输入。如果后台作业尝试从终端中读取输入,就会接收到一个SIGTTIN信号。SIGTTIN信号的默认处理动作是停止作业。
在上一个例子以及后面的几个例子中可能不需要按下回车键就能看到作业状态变更信息。根据内核的调度决策,shell可能会在打印下一个shell提示符之前接收到有关后台作业状态变更的通知。
现在必须要将作业移到前台来(fg)并向其提供所需的输入了。如果需要的话,可以通过先挂起该作业后在后台恢复该作业(bg)的方式继续该作业的执行。(当然,在这个特定的例子中,cat将会再次立即被停止,因为它会再次尝试从终端中读取输入。)
在默认情况下,后台作业是被允许向控制终端输入内容的。但如果终端设置了TOSTOP标记(终端输出停止),那么当后台作业尝试在终端上输出时会导致SIGTTOU信号的产生。(使用stty命令能够设置TOSTOP标志。)与SIGTTIN信号一样,SIGTTOU信号会停止作业。
$ stty tostop
$ date &
[1] 28626
# 回车
[1]+ Stopped date
可以通过将作业移到前台来查看作业的输出。
$ fg
date
Fri Feb 14 23:38:59 CST 2020
作业具备多种状态,作业控制以及shell命令和终端字符(以及相应的信号)可以使作业在不同状态之间迁移,下图对作业的状态进行了总结。这些作业可以通过向作业发送各种信号来到达,如SIGINT和SIGQUIT信号,而这些信号可以通过键盘来生成。
实现作业控制
尽管作业控制一开始在POSIX.1标准中是可选的,但在后面的标准中,包括SUSv3,则要求实现必须要支持作业控制。这种支持所需的条件如下。
- 实现必须要提供特定的作业控制信号:SIGTSTP、SIGSTOP、SIGCONT、SIGTTOU以及SIGTTIN。此外,SIGCHLD信号也是必需的,因为它允许shell(所有任务的父进程)找出其子进程何时执行终止或被停止了。
- 终端驱动器必须要支持作业控制信号的生成,这样当输入特定的字符或进行终端I/O以及在后台作业中执行特定的其他终端操作时需要将恰当的信号发送到相关的进程组。为了能够完成这些动作,终端驱动器必须要记录与终端相关联的会话ID(控制进程)和前台进程组ID。
- shell必须要支持作业控制(大多数现代shell都具备这个功能)。这种支持是通过前面介绍的将作业在前台和后台之间迁移以及监控作业的状态的命令的形式来完成的。其中某些命令会向作业发送信号。此外,在执行将作业从前台运行的状态迁移至其他状态的操作中,shell使用tcsetpgrp()调用来调整终端驱动器中与前台进程组有关的记录信息。
信号一般只有在发送进程的真实或有效用户ID与接收进程的真实用户ID或保存的set-user-ID匹配时才会被发送给进程,但SIGCONT是这个规则的一个例外。内核允许一个进程(如shell)向同一会话中的任意进程发送SIGCONT信号,不管进程的验证信息是什么。在SIGCONT信号上放宽这个规则是有必要的,这样当用户开始一个会修改自身的验证信息(特别是真实的用户ID)的set-user-ID程序时,仍然能够在程序被停止时通过SIGCONT信号来恢复这个程序的运行。
SIGTTIN和SIGTTOU信号
SUSv3对后台进程的SIGTTIN和SIGTTOU信号的产生规定了一些特殊情况(Linux实现了这些规定)。
- 当进程当前处于阻塞状态或忽视SIGTTIN信号的状态时则不发送SIGTTIN信号,这时试图从控制终端发起read()调用会失败,errno会被设置成EIO。这种行为的逻辑是没有这种行为的话进程就无法知道不允许进行read()操作。
- 即使终端被设置了TOSTOP标记,当进程当前处于阻塞状态或忽视SIGTTIN信号的状态时也不发送SIGTTOU信号。这时从控制终端发起write()调用是允许的(即TOSTOP标记被忽视了)。
- 不管是否设置了TOSTOP标记,当后台进程试图在控制终端上调用会修改终端驱动器数据结构的特定函数时会生成SIGTTOU信号。这些函数包括tcsetpgrp()、tcsetattr()、tcflush()、tcflow()、tcsendbreak()以及tcdrain()。如果SIGTTOU信号被阻塞或被忽视了,那么这些调用就会成功。
处理作业控制信号
由于对于大多数应用程序来讲作业控制的操作是透明的,因此它们无需对作业控制信号采取特殊的动作,但像vi和less之类的进行屏幕处理的程序则是例外,因为它们需要控制文本在终端上的布局和修改各种终端设置,包括允许在某一时刻从终端输入中读取一个字符(不是一行)的设置。
屏幕处理程序需要处理终端停止信号(SIGTSTP)。信号处理器应该将终端重置为规范(每次一行)输入模式并将光标放在终端的左下角。当进程恢复之后,程序会将终端设置回所需的模式,检查终端窗口大小(窗口大小同时可能会被用户改掉)以及使用所需的内容重新绘制屏幕。
当挂起或退出诸如vi、xterm或其他终端处理程序时通常会看到程序使用启动之前的可见文本来绘制终端。这些终端处理程序是通过捕获两个字符序列来取得这种效果的,所有使用terminfo或termcap包的程序在取得和释放终端布局的控制时都需要输出这两个字符序列。第一个字符序列称为smcup(通常是Escape后面跟着[?1049h),它会导致终端处理程序切换至其“预备”屏幕。第二个序列称为rmcup(通常是Escape后面跟着[?1049l),它会导致终端处理程序恢复到默认屏幕,从而导致在显示器上重现屏幕处理程序在获取终端的控制权之前的初始文本。
在处理SIGTSTP信号时需要清楚一些细节问题。第一个问题是:如果SIGTSTP信号被捕获了,那么就不会执行默认的停止进程的动作。可以通过让SIGTSTP信号的处理器生成一个SIGSTOP信号来解决这个问题。由于SIGSTOP信号是无法被捕获、阻塞和忽略的,因此能确保立即停止进程,但这种方式不是非常准确。父进程可以使用wait()或waitpid()返回的等待状态值来确定哪个信号导致了其子进程的停止。如果在SIGTSTP信号处理器中生成了SIGSTOP信号,那么对于父进程来讲,其子进程是被SIGSTOP信号停止的,这就会产生误导。
在这种情况下,恰当的处理方式是让SIGTSTP信号处理器再生成一个SIGTSTP信号来停止进程,如下所示。
1. 处理器将SIGTSTP信号的处理重置为默认值(SIG_DFL)。
2. 处理器生成SIGTSTP信号。
3. 由于SIGTSTP信号会被阻塞进入处理器(除非指定了SA_NODEFER标记),因此处理器会接触该信号的阻塞。这时,在上一个步骤中生成的SIGTSTP信号会导致默认动作的执行:进程会立即被挂起。
4. 在后面的某个时刻,当进程接收到SIGCONT信号时会恢复。这时,处理器的执行就会继续。
5. 在返回之前,处理器会重新阻塞SIGTSTP信号并重新注册本身来处理下一个SIGTSTP信号。
执行重新阻塞SIGTSTP信号这一步是因为防止在处理器重新注册本身之后和返回之前接收到另一个SIGTSTP信号而导致处理器被递归调用的情况。在快速发送信号时递归调用一个信号处理器会导致栈溢出。阻塞信号还避免了信号处理器在重新注册本身和返回之前需要执行其他动作(如保存和还原全局变量)时存在的问题。
孤儿进程组(SIGHUP回顾)
孤儿进程是那些在父进程终止之后被init进程(进程ID为1)收养的进程。在程序中可以使用下面的代码创建一个孤儿进程。
if(fork()!=0) //如果是父进程就退出
return 0;
假设在shell中执行一个包含上面这段代码的程序,下图给出了父进程终止前后该进程的状态。
在父进程终止之后,子进程不仅是一个孤儿进程,同时也是孤儿进程组的一个成员。SUSv3认为当一个进程组满足“每个成员的父进程本身是组的一个成员或不是组会话的一个成员”时就变成了一个孤儿进程组。换句话说,如果一个进程组中至少有一个成员拥有一个位于同一会话但不同进程组中的父进程,就不是孤儿进程组。上图中包含子进程的进程组是孤儿进程组,因为进程组中的子进程是唯一进程,其父进程(init)位于不同的会话中。
根据定义,会话首进程位于孤儿进程组中。这是因为setsid()在新会话中创建了一个新进程组,而会话首进程的父进程则位于不同的会话中。
从shell作业控制的角度来讲,孤儿进程组是非常重要的。根据上图考虑下面的场景。
1. 在父进程退出之前,子进程被停止了(可能是由于父进程向子进程发送了一个停止信号)。
2. 当父进程退出时shell从作业列表中删除了父进程的进程组。子进程由init收养并变成了终端的一个后台进程,包含该子进程的进程组变成了孤儿进程组。
3. 这时没有进程会通过wait()监控被停止的子进程的状态。
由于shell并没有创建子进程,因此它不清楚子进程是否存在以及子进程与已经退出的父进程位于同一个进程组中。此外,init进程只会检查被终止的子进程并清理该僵尸进程,从而导致被停止的子进程可能会永远残留在系统中,因为没有进程知道要向其发送一个SIGCONT信号来恢复它的执行。
即使孤儿进程组中一个被停止的进程拥有一个仍然存活但位于不同会话中的父进程,也无法保证父进程能够向这个被停止的子进程发送SIGCONT信号。一个进程可以向同一会话中的任意其他进程发送SIGCONT信号,但如果子进程位于不同的会话中,发送信号的标准规则就开始起作用了,因此如果子进程是一个修改了自身的验证信息的特权进程,父进程可能就无法向子进程发送信号。
为防止上面所描述的情况的发生,SUSv3规定,如果一个进程组变成了孤儿进程组并且拥有很多已停止执行的成员,那么系统会向进程组中的所有成员发送一个SIGHUP信号通知它们已经与会话断开连接了,之后再发送一个SIGCONT信号确保它们恢复执行。如果孤儿进程组不包含被停止的成员,那么就不会发送任何信号。
一个进程组变成孤儿进程组的原因可能是因为最后一个位于不同进程组但属于同一会话的父进程终止了,也可能是因为父进程位于另一个进程组中的进程组中最后一个进程终止了。不管是何种原因引起的,对包含被停止的子进程的新孤儿进程组的处理是一样的。
向包含被停止的成员的新孤儿进程组发送SIGHUP和SIGCONT信号是为了消除任务控制框架中的特定漏洞,因为没有任何措施能够防止一个进程(拥有合适的权限)向孤儿进程组中的成员发送停止信号来停止它们。这样,进程就会保持在停止的状态,直到一些进程(同样需要拥有合适的权限)向它们发送一个SIGCONT信号。
孤儿进程组中的成员在调用tcsetpgrp()函数时会得到ENOTTY的错误,在调用tcsetattr()、tcflush()、tcflow()、tcsendbreak()和tcdrain()函数时会得到EIO的错误。
进程优先级和调度
进程优先级(nice值)
Linux与大多数其他UNIX实现一样,调度进程使用CPU的默认模型是循环时间共享。在这种模型中,每个进程轮流使用CPU一段时间,这段时间被称为时间片或量子。循环时间共享满足了交互式多任务系统的两个重要需求。
- 公平性:每个进程都有机会用到CPU。
- 响应度:一个进程在使用CPU之前无需等待太长的时间。
在循环时间共享算法中,进程无法直接控制何时使用CPU以及使用CPU的时间。在默认情况下,每个进程轮流使用CPU直至时间片被用光或自己自动放弃CPU(如进行睡眠或执行一个磁盘读取操作)。如果所有进程都试图尽可能多地使用CPU(即没有进程会睡眠或被I/O操作阻塞),那么它们使用CPU的时间差不多是相等的。
进程特性nice值允许进程间接地影响内核的调度算法。每个进程都拥有一个nice值,其取值范围为−20(高优先级)~19(低优先级),默认值为0。在传统的UNIX实现中,只有特权进程才能够赋给自己(或其他进程)一个负(高)优先级。非特权进程只能降低自己的优先级,即赋一个大于默认值0的nice值。这样做之后它们就对其他进程“友好(nice)”了,这个特性的名称也由此而来。
使用fork()创建子进程时会继承nice值并且该值会在exec()调用中得到保持。
getpriority()系统调用服务例程不会返回实际的nice值,相反,它会返回一个范围在1(低优先级)~40(高优先级)之间的数字,这个数字是通过公式unice=20-knice计算得来的。这样做是为了避免让系统调用服务例程返回一个负值,因为负值一般都表示错误。应用程序是不清楚系统调用服务例程对返回值所做的处理的,因为C库函数getpriority()做了相反的计算操作,它将20-unice值返回给了调用程序。
nice值的影响
进程的调度不是严格按照nice值的层次进行的,相反,nice值是一个权重因素,它导致内核调度器倾向于调度拥有高优先级的进程。给一个进程赋一个低优先级(即高nice值)并不会导致它完全无法用到CPU,但会导致它使用CPU的时间变少。nice值对进程调度的影响程度则依据Linux内核版本的不同而不同,同时在不同UNIX系统之间也是不同的。
从版本号为2.6.23的内核开始,nice值之间的差别对新内核调度算法的影响比对之前的内核中的调度算法的影响要强。因此,低nice值的进程使用CPU的时间将比以前少,高nice值的进程占用CPU的时间将大大提高。
获取和修改优先级
getpriority()和setpriority()系统调用允许一个进程获取和修改自身或其他进程的nice值。
#include<sys/resource.h>
int getpriority(int which,id_t who);
int setpriority(int which,id_t who,int prio);
两个系统调用都接收参数which和who,这两个参数用于标识需读取或修改优先级的进程。which参数确定who参数如何被解释。这个参数的取值为下面这些值中的一个。
PRIO_PROCESS
操作进程ID为who的进程。如果who为0,那么使用调用者的进程ID。
PRIO_PGRP
操作进程组ID为who的进程组中的所有成员。如果who为0,那么使用调用者的进程组。
PRIO_USER
操作所有真实用户ID为who的进程。如果who为0,那么使用调用者的真实用户ID。
who参数的类型id_t是一个大小能容纳进程ID或用户ID的整型。
getpriority()系统调用返回由which和who指定的进程的nice值。如果有多个进程符合指定的标准(当which为PRIO_PGRP或PRIO_USER时会出现这种情况),那么将会返回优先级最高的进程的nice值(即最小的数值)。由于getpriority()可能会在成功时返回−1,因此在调用这个函数之前必须要将errno设置为0,接着在调用之后检查返回值为−1以及errno不为0才能确认调用成功。
setpriority()系统调用会将由which和who指定的进程的nice值设置为prio。试图将nice值设置为一个超出允许范围的值(-20~+19)时会直接将nice值设置为边界值。
以前nice值是通过调用nice(incr)来完成的,这个函数会将调用进程的nice值加上incr。现在这个函数仍然是可用的,但已经被更通用的setpriority()系统调用所取代了。
在命令行中与setpriority()系统调用实现类似功能的命令是nice(1),非特权用户可以使用这个命令来运行一个优先级更低的命令,特权用户则可以运行一个优先级更高的命令,超级用户则可以使用renice(8)来修改既有进程的nice值。
特权进程(CAP_SYS_NICE)能够修改任意进程的优先级。非特权进程可以修改自己的优先级(将which设为PRIO_PROCESS,who设为0)和其他(目标)进程的优先级,前提是自己的有效用户ID与目标进程的真实或有效用户ID匹配。Linux中setpriority()的权限规则与SUSv3中的规则不同,它规定当非特权进程的真实或有效用户ID与目标进程的有效用户ID匹配时,该进程就能修改目标进程的优先级。UNIX实现在这一点上与Linux有些不同。一些实现遵循的SUSv3的规则,而另一些——特别是BSD系列——与Linux的行为方式一样。
版本号小于2.6.12的Linux内核与之后的内核对非特权进程调用setpriority()时使用的权限规则不同(也与SUSv3不同)。当非特权进程的真实或有效用户ID与目标进程的真实用户ID匹配时,该进程就能修改目标进程的优先级。从Linux 2.6.12开始,权限检查变得与Linux中类似的API一致了,如sched_setscheduler()和sched_setaffinity()。
在版本号小于2.6.12的Linux内核中,非特权进程只能使用setpriority()来降低(不可逆的)自己或其他进程的nice值。特权进程(CAP_SYS_NICE)可以使用setpriority()来提高nice值。
从版本号为2.6.12的内核开始,Linux提供了RLIMIT_NICE资源限制,即允许非特权进程提升nice值。非特权进程能够将自己的nice值最高提高到公式20−rlim_cur指定的值,其中rlim_cur是当前的RLIMIT_NICE软资源限制。如假设一个进程的RLIMIT_NICE软限制是25,那么其nice值可以被提高到−5。根据这个公式以及nice值的取值范围为+19(低)~−20(高)的事实可以得出RLIMIT_NICE的有效范围为1(低)~40(高)的结论。(RLIMIT_NICE没有使用范围为+19~−20之间的值,因为一些负的资源限制值具有特殊含义——如RLIM_INFINITY可以为−1。)
非特权进程能够通过setpriority()调用来修改其他(目标)进程的nice值,前提是调用setpriority()的进程的有效用户ID与目标进程的真实或有效用户ID匹配并且对nice值的修改符合目标进程的RLIMIT_NIC限制。
实时进程调度概述
在一个系统上一般会同时运行交互式进程和后台进程,标准的内核调度算法一般能够为这些进程提供足够的性能和响应度。但实时应用对调度器有更加严格的要求,如下所示。
- 实时应用必须要为外部输入提供担保最大响应时间。在很多情况下,这些担保最大响应时间必须非常短(如低于秒级)。如交通导航系统的慢速响应可能会使一个灾难。为了满足这种要求,内核必须要提供工具让高优先级进程能快速地取得CPU的控制权,抢占当前运行的所有进程。
- 高优先级进程应该能够保持互斥地访问CPU直至它完成或自动释放CPU。
- 实时应用应该能够精确地控制其组件进程的调度顺序。
SUSv3规定的实时进程调度 API(原先在POSIX.1b中定义)部分满足了这些要求。这个API提供了两个实时调度策略:SCHED_RR和SCHED_FIFO。使用这两种策略中任意一种策略进行调度的进程的优先级要高于使用上一节中介绍的标准循环时间分享策略来调度的进程,实时调度API使用常量SCHED_OTHER来标识这种循环时间分享策略。
每个实时策略允许一个优先级范围。SUSv3要求实现至少要为实时策略提供32个离散的优先级。在每个调度策略中,拥有高优先级的可运行进程在尝试访问CPU时总是优先于优先级较低的进程。
对于多处理器Linux系统(包括超线程系统)来讲,高优先级的可运行进程总是优先于优先级较低的进程的规则并不适用。在多处理器系统中,各个CPU拥有独立的运行队列(这种方式比使用一个系统层面的运行队列的性能要好),并且每个CPU的运行队列中的进程的优先级都局限于该队列。如假设一个双处理器系统中运行着三个进程,进程A的实时优先级为20,并且它位于CPU 0的等待队列中,而该CPU当前正在运行优先级为30的进程B,即使CPU 1正在运行优先级为10的进程C,进程A还是需要等待CPU 0。
包含多个进程的实时应用可以使用CPU亲和力API来避免这种调度行为可能引起的问题。如在一个四处理器系统中,所有非关键的进程可以被分配到一个CPU中,让其他三个CPU处理实时应用。
Linux提供了 99个实时优先级,其数值从1(最低)~99(最高),并且这个取值范围同时适用于两个实时调度策略。每个策略中的优先级是等价的。这意味着如果两个进程拥有同样的优先级,一个进程采用了SCHED_RR的调度策略,另一个进程采用了SCHED_FIFO的调度策略,那么两个都符合运行的条件,至于到底运行哪个则取决于它们被调度的顺序了。实际上,每个优先级级别都维护着一个可运行的进程队列,下一个运行的进程是从优先级最高的非空队列的队头选取出来的。
POSIX实时与硬实时对比
满足本节开头处列出的所有要求的应用程序有时候被称为硬实时应用程序。但POSIX实时进程调度API无法满足这些要求。特别是它没有为应用程序提供一种机制来确保处理输入的响应时间,而这种机制需要操作系统的提供相应的特性,但Linux内核并没有提供这种特性(大多数其他标准的操作系统也没有提供这种特性)。POSIX API仅仅提供了所谓的软实时,允许控制调度哪个进程使用CPU。
在不给系统增加额外开销的情况下增加对硬实时应用程序的支持是非常困难的,这种新增的开销通常与时间分享应用程序的性能要求是存在冲突的,而典型的桌面和服务器系统上运行的应用程序大部分都是时间分享应用程序。这就是为何大多数UNIX内核——包括原来的Linux——并没有为实时应用程序提供原生支持的原因。但从版本2.6.18开始,各种特性都被添加到了Linux内核中,从而允许Linux为硬实时应用程序提供了完全的原生支持,同时不会给时间分享应用程序增加前面提及到的开销。
SCHED_RR策略
在SCHED_RR(循环)策略中,优先级相同的进程以循环时间分享的方式执行。进程每次使用CPU的时间为一个固定长度的时间片。一旦被调度执行之后,使用SCHED_RR策略的进程会保持对CPU的控制直到下列条件中的一个得到满足:
- 达到时间片的终点了;
- 自愿放弃CPU,这可能是由于执行了一个阻塞式系统调用或调用了sched_yield()系统调用(35.3.3节将予以介绍);
- 终止了;
- 被一个优先级更高的进程抢占了。
对于上面列出的前两个事件,当运行在SCHED_RR策略下的进程丢掉CPU之后将会被放置在与其优先级级别对应的队列的队尾。在最后一种情况中,当优先级更高的进程执行结束之后,被抢占的进程会继续执行直到其时间片的剩余部分被消耗完(即被抢占的进程仍然位于与其优先级级别对应的队列的队头)。
在SCHED_RR和SCHED_FIFO两种策略中,当前运行的进程可能会因为下面某个原因而被抢占:
- 之前被阻塞的高优先级进程解除阻塞了(如它所等待的I/O操作完成了);
- 另一个进程的优先级被提到了一个级别高于当前运行的进程的优先级的优先级;
- 当前运行的进程的优先级被降低到低于其他可运行的进程的优先级了。
SCHED_RR策略与标准的循环时间分享调度算法(SCHED_OTHER)类似,即它也允许优先级相同的一组进程分享CPU时间。它们之间最重要的差别在于SCHED_RR策略存在严格的优先级级别,高优先级的进程总是优先于优先级较低的进程。而在SCHED_OTHER策略中,低nice值(即高优先级)的进程不会独占CPU,它仅仅在调度决策时为进程提供了一个较大的权重。一个优先级较低的进程(即高nice值)总是至少会用到一些CPU时间的。它们之间另一个重要的差别是SCHED_RR策略允许精确控制进程被调用的顺序。
SCHED_FIFO策略
SCHED_FIFO(先入先出,first-in,first-out)策略与SCHED_RR策略类似,它们之间最主要的差别在于在SCHED_FIFO策略中不存在时间片。一旦一个SCHED_FIFO进程获得了CPU的控制权之后,它就会一直执行直到下面某个条件被满足:
- 自动放弃CPU(采用的方式与前面描述的SCHED_FIFO策略中的方式一样);
- 终止了;
- 被一个优先级更高的进程抢占了(场景与前面描述的SCHED_FIFO策略中场景一样)。
在第一种情况中,进程会被放置在与其优先级级别对应的队列的队尾。在最后一种情况中,当高优先级进程执行结束之后(被阻塞或终止了),被抢占的进程会继续执行(即被抢占的进程位于与其优先级级别对应的队列的队头)。
SCHED_BATCH和SCHED_IDLE策略
Linux 2.6系列的内核添加了两个非标准调度策略:SCHED_BATCH和SCHED_IDLE。尽管这些策略是通过POSIX实时调度API来设置的,但实际上它们并不是实时策略。
SCHED_BATCH策略是在版本为2.6.16的内核中加入的,它与默认的SCHED_OTHER策略类似,两个之间的差别在于SCHED_BATCH策略会导致频繁被唤醒的任务被调度的次数较少。这种策略用于进程的批量式执行。
SCHED_IDLE策略是在版本为2.6.23的内核中加入的,它也与SCHED_OTHER类似,但提供的功能等价于一个非常低的nice值(即低于+19)。在这个策略中,进程的nice值毫无意义。它用于运行低优先级的任务,这些任务在系统中没有其他任务需要使用CPU时才会大量使用CPU。
实时进程调用API
下面开始介绍构成实时进程调度API的各个系统调用。这些系统调用允许控制进程调度策略和优先级。
虽然从2.0内核开始实时调度已经是Linux的一部分了,但在实现中几个问题存在了很长时间。在2.2内核的实现中一些特性仍然无法工作,甚至在2.4内核的早期版本中也是同样的情况。其中大多数问题直到2.4.20内核才得以修正。
实时优先级范围
sched_get_priority_min()和sched_get_priority_max()系统调用返回一个调度策略的优先级取值范围。
#include<sched.h>
int sched_get_priority_min(int policy);
int sched_get_priority_max(int policy);
在两个系统调用中,policy指定了需获取哪种调度策略的信息。这个参数的取值一般是SCHED_RR或SCHED_FIFO。sched_get_priority_min()系统调用返回指定策略的最小优先级,sched_get_priority_max()返回最大优先级。在Linux上,这些系统调用为SCHED_RR和SCHED_FIFO策略分别返回范围为1到99的数字。换句话说,两个实时策略的优先级取值范围是完全一样的,并且优先级相同的SCHED_RR和SCHED_FIFO进程都具备被调度的资格。(至于哪个进程先被调度则取决于它们在优先级级别队列中的顺序。)
不同UNIX实现中的实时策略的取值范围是不同的。因此不能在应用程序中硬编码优先级值,相反,需要根据两个函数的返回值来指定优先级。因此,SCHED_RR策略中最低的优先级应该是sched_get_priority_min(SCHED_FIFO),比它高一级的优先级是sched_get_priority_min(SCHED_FIFO)+1,依此类推。
SUSv3并不要求SCHED_RR和SCHED_FIFO策略使用同样的优先级范围,但在大多数UNIX实现中都是这样做的。如在Solaris 8中两种策略的优先级范围是0~59,而在FreeBSD 6.1中的优先级范围是0~31。
修改和获取策略和优先级
修改调度策略和优先级
sched_setscheduler()系统调用修改进程ID为pid的进程的调度策略和优先级。如果pid为0,那么将会修改调用进程的特性。
#include <sched.h>
int sched_setscheduler(pid_t pid, int policy,const struct sched_param *param);
param参数是一个指向下面这种结构的指针。
struct sched_param
{
int sched_priority;
};
SUSv3将param参数定义成一个结构以允许实现包含额外的特定于实现的字段,当实现提供了额外的调度策略时这些字段可能会变得有用。但与大多数UNIX实现一样,Linux提供了sched_priority字段,该字段指定了调度策略。对于 SCHED_RR和SCHED_FIFO来讲,这个字段的取值必须位于 sched_get_priority_min()和 sched_get_priority_max()规定的范围内;对于其他策略来讲,优先级必须是0。
policy参数确定了进程的调度策略,它的取值为下表中的一个。
策 略 | 描 述 |
---|---|
SCHED_FIFO SCHED_RR |
实时先入先出 实时循环 |
SCHED_OTHER SCHED_BATCH SCHED_IDLE |
标准的循环时间分享 与SCHED_OTHER类似,但用于批量执行(自Linux 2.6.16起) 与SCHED_OTHER类似,但优先级比最大的nice值(+19)还要低(自Linux 2.6.23起) |
成功调用sched_setscheduler()会将pid指定的进程移到与其优先级级别对应的队列的队尾。
SUSv3规定成功调用sched_setscheduler()时其返回值应该是上一种调度策略。但Linux并没有遵循这个规则,在成功调用时该函数会返回0。一个可移植的应用程序应该通过检查返回值是否不为−1来判断调用是否成功。
通过fork()创建的子进程会继承父进程的调度策略和优先级,并且在exec()调用中会保持这些信息。
sched_setparam()系统调用提供了 sched_setscheduler()函数的一个功能子集。它修改一个进程的调度策略,但不会修改其优先级。
#include <sched.h>
int sched_setparam(pid_t pid, const struct sched_param *param);
pid和param参数与sched_setscheduler()中相应的参数是一样的。
成功调用sched_setparam()会将pid指定的进程移到与其优先级级别对应的队列的队尾。
权限和资源限制会影响对调度参数的变更
在2.6.12之前的内核中,进程必须要先变成特权进程(CAP_SYS_NICE)才能够修改调度策略和优先级。这个规则的一个例外情况是非特权进程在调用者的有效用户ID与目标进程的真实或有效用户 ID 匹配时就能将该进程的调度策略修改为SCHED_OTHER。
从2.6.12的内核开始,设置实时调度策略和优先级的规则发生了变动,即引入了一个全新的非标准的资源限制RLIMIT_RTPRIO。在老式内核中,特权(CAP_SYS_NICE)进程能够随意修改任意进程的调度策略和优先级。同时,非特权进程也能够根据下列规则修改调度策略和优先级。
- 如果进程拥有非零的RLIMIT_RTPRIO软限制,那么它就能随意修改自己的调度策略和优先级,只要符合实时优先级的上限为其当前实时优先级(如果该进程当前运行于一个实时策略下)的最大值及其RLIMIT_RTPRIO软限制值的约束即可。
- 如果进程的RLIMIT_RTPRIO软限制值为0,那么进程只能降低自己的实时调度优先级或从实时策略切换非实时策略。
- SCHED_IDLE策略是一种特殊的策略。运行在这个策略下的进程无法修改自己的策略,不管RLIMIT_RTPRIO资源限制的值是什么。
- 在其他非特权进程中也能执行策略和优先级的修改工作,只要该进程的有效用户ID与目标进程的真实或有效用户ID匹配即可。
- 进程的软RLIMIT_RTPRIO限制值只能确定可以对自己的调度策略和优先级做出哪些变更,这些变更可以由进程自己发起,也可以由其他非特权进程发起。拥有非零限制值的非特权进程无法修改其他进程的调度策略和优先级。
获取调度策略和优先级
sched_getscheduler()和sched_getparam()系统调用获取进程的调度策略和优先级。
#include<sched.h>
int sched_getscheduler(pid_t pid);
int sched_getparam(pid_t pid,struct sched_param *param);
在这两个系统调用中,pid指定了需查询信息的进程ID。如果pid为0,那么就会查询调用进程的信息。两个系统调用都可被非特权进程用来获取任意进程的信息,而不管进程的验证信息是什么。
sched_getparam()系统调用返回由param指向的sched_param结构中sched_priority字段指定的进程的实时优先级。
如果执行成功,sched_getscheduler()将会返回前面表中列出的一个策略。
防止实时进程锁住系统
由于SCHED_RR和SCHED_FIFO进程会抢占所有低优先级的进程(如运行这个程序的shell),因此在开发使用这些策略的应用程序时需要小心可能会发生失控的实时进程因一直占住CPU而导致锁住系统的情况。在程序中可以通过一些方法来避免这种情况的发生。
- 使用setrlimit()设置一个合理的低软 CPU 时间组员限制。如果进程消耗了太多的 CPU 时间,那么它将会收到一个SIGXCPU信号,该信号在默认情况下会杀死该进程。
- 使用alarm()设置一个警报定时器。如果进程的运行时间超出了由alarm()调用指定的秒数,那么该进程会被SIGALRM信号杀死。
- 创建一个拥有高实时优先级的看门狗进程。这个进程可以进行无限循环,每次循环都睡眠指定的时间间隔,然后醒来并监控其他进程的状态。这种监控可以包含对每个进程消耗的CPU时间的度量,并使用sched_getscheduler()和sched_getparam()来检查进程的调度策略和优先级。如果一个进程看起来行为异常,那么看门狗线线程可以降低该进程的优先级或向其发送合适的信号来停止或终止该进程。
- 从2.6.25的内核开始,Linux提供了一个非标准的资源限制RLIMIT_RTTIME用于控制一个运行在实时调度策略下的进程在单次运行中能够消耗的CPU时间。RLIMIT_RTTIME的单位是毫秒,它限制了一个进程在不执行阻塞式系统调用时能够消耗的CPU时间。当进程执行了这样的系统调用时,累积消耗的CPU时间将会被重置为0。当这个进程被一个优先级更高的进程抢占时,累积消耗的CPU时间不会被重置。当进程的时间片被耗完或调用sched_yield()(参见35.3.3节)时进程会放弃CPU。当进程达到了CPU时间限制RLIMIT_CPU之后,系统会向其发送一个SIGXCPU信号,该信号在默认情况下会杀死这个进程。
避免子进程进程特权调度策略
Linux 2.6.32增加了一个SCHED_RESET_ON_FORK,在调用sched_setscheduler()时可以将policy参数的值设置为该常量。系统会将这个标记值与表35-1中列出的其中一个策略取OR。如果设置了这个标记,那么由这个进程使用fork()创建的子进程就不会继承特权调度策略和优先级了。其规则如下。
- 如果调用进程拥有一个实时调度策略(SCHED_RR或SCHED_FIFO),那么子进程的策略会被重置为标准的循环时间分享策略SCHED_OTHER。
- 如果进程的nice值为负值(即高优先级),那么子进程的nice值会被重置为0。
SCHED_RESET_ON_FORK标记用于媒体回放应用程序,它允许创建单个拥有实时调度策略但不会将该策略传递给子进程的进程。使用SCHED_RESET_ON_FORK标记能够通过创建多个运行于实时调度策略下的子进程来防止创建试图超出RLIMIT_RTTIME资源限制的子进程。
一旦进程启用了SCHED_RESET_ON_FORK标记,那么只有特权进程(CAP_SYS_NICE)才能够禁用该标记。当子进程被创建出来之后,它的reset-on-fork标记会被禁用。
释放CPU
实时进程可以通过两种方式自愿释放CPU:通过调用一个阻塞进程的系统调用(如从终端中read())或调用sched_yield()。
#include<sched.h>
int sched_yield(void);
sched_yield()的操作是比较简单的。如果存在与调用进程的优先级相同的其他排队的可运行进程,那么调用进程会被放在队列的队尾,队列中队头的进程将会被调度使用CPU。如果在该优先级队列中不存在可运行的进程,那么sched_yield()不会做任何事情,调用进程会继续使用CPU。
虽然SUSv3允许sched_yield()返回一个错误,但在Linux或很多其他UNIX实现上这个系统调用总会成功。可移植的应用程序应该总是检查这个系统调用是否返回错误。
非实时进程使用sched_yield()的结果是未定义的。
SCHED_RR时间片
通过sched_rr_get_interval()系统调用能够找出SCHED_RR进程在每次被授权使用CPU时分配到的时间片的长度。
#include<sched.h>
int sched_rr_get_interval(pid_t pid,struct timespec *tp);
与其他进程调度系统调用一样,pid标识出了需查询信息的进程,当pid为0时表示调用进程。返回的时间片是由tp指向的timespec结构。
struct timespec
{
time_t tv_sec;
long tv_nsec;
};
在最新的2.6内核中,实时循环时间片是0.1秒。
CPU亲和力
当一个进程在一个多处理器系统上被重新调度时无需在上一次执行的CPU上运行。之所以会在另一个 CPU 上运行的原因是原来的CPU处于忙碌状态。
进程切换CPU时对性能会有一定的影响:如果在原来的CPU的高速缓冲器中存在进程的数据,那么为了将进程的一行数据加载进新 CPU 的高速缓冲器中,首先必须使这行数据失效(即在没被修改的情况下丢弃数据,在被修改的情况下将数据写入内存)。(为防止高速缓冲器不一致,多处理器架构在某个时刻只允许数据被存放在一个CPU的高速缓冲器中。)这个使数据失效的过程会消耗时间。由于存在这个性能影响,Linux(2.6)内核尝试了给进程保证软 CPU 亲和力——在条件允许的情况下进程重新被调度到原来的CPU 上运行。
有时候需要为进程设置硬CPU亲和力,这样就能显式地将其限制在可用CPU中的一个或一组CPU上运行。之所以需要这样做,原因如下。
- 可以避免由使高速缓冲器中的数据失效所带来的性能影响。
- 如果多个线程(或进程)访问同样的数据,那么当将它们限制在同样的CPU上的话可能会带来性能提升,因为它们无需竞争数据并且也不存在由此而产生的高速缓冲器未命中。
- 对于时间关键的应用程序来讲,可能需要为此应用程序预留一个或更多CPU,而将系统中大多数进程限制在其他CPU上。
Linux 2.6提供了一对非标准的系统调用来修改和获取进程的硬CPU亲和力:sched_ setaffinity()和sched_getaffinity()。
很多其他UNIX实现提供了控制CPU亲和力的接口,如HP-UX和Solaris提供了pset_bind()系统调用。
sched_setaffinity()系统调用设置了pid指定的进程的CPU亲和力。如果pid为0,那么调用进程的CPU亲和力就会被改变。
#define _GNU_SOURCE
#include<sched.h>
int sched_setaffinity(pid_t pid,size_t len,cpu_set_t *set);
赋给进程的CPU亲和力由set指向的cpu_set_t结构来指定。
实际上CPU亲和力是一个线程级特性,可以调整线程组中各个进程的CPU亲和力。如果需要修改一个多线程进程中某个特定线程的CPU亲和力的话,可以将pid设定为线程中gettid()调用返回的值。将pid设为0表示调用线程。
虽然cpu_set_t数据类型实现为一个位掩码,但应该将其看成是一个不透明的结构。所有对这个结构的操作都应该使用宏CPU_ZERO()、CPU_SET()、CPU_CLR()和CPU_ISSET()来完成。
#define _GNU_SOURCE
#include<sched.h>
void CPU_ZERO(cpu_set_t *set);
void CPU_SET(int cpu,cpu_set_t *set);
void CPU_CLR(int cpu,cpu_set_t *set);
int CPU_ISSET(int cpu,cpu_set_t *set);
下面这些宏操作set指向的CPU集合:
- CPU_ZERO()将set初始化为空。
- CPU_SET()将CPU cpu添加到set中。
- CPU_CLR()从set中删除CPU cpu。
- CPU_ISSET()在CPU cpu是set的一个成员时返回true。
CPU集合中的CPU从0开始编号。<sched.h>头文件定义了常量CPU_SETSIZE,它是比cpu_set_t变量能够表示的最大CPU编号还要大的一个数字。CPU_SETSIZE的值为1024。
传递给sched_setaffinity()的len参数应该指定set参数中字节数(即sizeof(cpu_set_t))。
下面的代码将pid标识出的进程限制在四处理器系统上除第一个CPU之外的任意CPU上运行。
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(1,&set);
CPU_SET(2,&set);
CPU_SET(3,&set);
sched_setaffinity(pid,CPU_SERSIZE,&set);
如果set中指定的CPU与系统中的所有CPU都不匹配,那么sched_setaffinity()调用就会返回EINVAL错误。
如果运行调用进程的CPU不包含在set中,那么进程会被迁移到set中的一个CPU上。
非特权进程只有在其有效用户ID与目标进程的真实或有效用户ID匹配时才能够设置目标进程的CPU亲和力。特权(CAP_SYS_NICE)进程可以设置任意进程的CPU亲和力。
sched_getaffinity()系统调用获取pid指定的进程的CPU亲和力掩码。如果pid为0,那么就返回调用进程的CPU亲和力掩码。
#define _GNU_SOURCE
#include<sched.h>
int sched_getaffinity(pid_t pid,size_t len,cpu_set_t *set);
返回的 CPU 亲和力掩码位于set指向的 cpu_set_t 结构中,同时应该将len参数设置为结构中包含的字节数,即 sizeof(cpu_set_t)。使用 CPU_ISSET()宏能够确定哪些 CPU 位于set中。
如果目标进程的CPU亲和力掩码并没有被修改过,那么sched_getaffinity()返回包含系统中所有CPU的集合。
sched_getaffinity()执行时不会进行权限检查,非特权进程能够获取系统上所有进程的CPU亲和力掩码。
通过fork()创建的子进程会继承其父进程的CPU亲和力掩码并且在exec()调用之间掩码会得以保留。
sched_setaffinity()和sched_getaffinity()系统调用是Linux特有的。
进程资源
进程资源使用
getrusage()系统调用返回调用进程或其子进程用掉的各类系统资源的统计信息。
#include <sys/resource.h>
int getrusage(int who, struct rusage *usage);
who参数指定了需查询资源使用信息的进程,其取值为下列几个值中的一个。
RUSAGE_SELF
返回调用进程相关的信息。
RUSAGE_CHILDREN
返回调用进程的所有被终止和处于等待状态的子进程相关的信息。
RUSAGE_THREAD(自Linux 2.6.26起)
返回调用线程相关的信息。这个值是Linux特有的。
res_usage参数是一个指向rusage结构的指针,其定义如下所示。
struct rusage {
struct timeval ru_utime; // user time used
struct timeval ru_stime; // system time used
long ru_maxrss; // maximum resident set size
long ru_ixrss; // integral shared memory size
long ru_idrss; // integral unshared data size
long ru_isrss; // integral unshared stack size
long ru_minflt; // page reclaims
long ru_majflt; // page faults
long ru_nswap;// swaps
long ru_inblock; // block input operations
long ru_oublock; // block output operations
long ru_msgsnd; // messages sent
long ru_msgrcv; //messages received
long ru_nsignals; // signals received
long ru_nvcsw; // voluntary context switches
long ru_nivcsw; // involuntary context switches
};
在Linux上,在调用getrusage()(或wait3()以及wait4())时,rusage结构中的很多字段都不会被填充,只有最新的内核才会填充这些字段。其中一些字段在Linux中并没有用到,只有UNIX实现用到了这些字段。而Linux系统之所以也提供了这些字段是为了防止以后扩展时需要修改rusage结构而破坏既有的应用程序库。
虽然大多数UNIX实现都提供了getrusage(),但SUSv3并没有全面规范这个系统调用(仅规定了ru_utime和ru_stime字段),这样做的部分原因是因为rusage结构中的很多字段的含义是依赖于实现的。
ru_utime和ru_stime字段的类型是timeval结构,它分别表示一个进程在用户模式和内核模式下消耗的CPU的秒数和毫秒数。
Linux特有的/proc/PID/stat文件提供了系统中所有进程的某些资源使用信息(CPU时间和页面错误).
getrusage() RUSAGE_CHILDREN操作返回的rusage结构中包含了调用进程的所有子孙进程的资源使用统计信息。如假设三个进程之间的关系为父进程、子进程和孙子进程,那么当子进程在wait()孙子进程时,孙子进程的资源使用值就会被加到子进程的RUSAGE_CHILDREN值上,当父进程执行了一个wait()子进程的操作时,子进程和孙子进程的资源使用信息就会被加到父进程的RUSAGE_CHILDREN值上。而如果子进程没有wait()孙子进程的话,孙子进程的资源使用就不会被记录到父进程的RUSAGE_CHILDREN值中。
在RUSAGE_CHILDREN操作中,ru_maxrss字段返回调用进程的所有子孙进程中最大驻留集大小(不是所有子孙进程之和)。
SUSv3规定当SIGCHLD被忽略时(这样子进程就不会变成可等待的僵死进程了),子进程的统计信息不应该被加到RUSAGE_CHILDREN的返回值中。但在26.3.3节中曾经指出过在版本号早于2.6.9的内核中,Linux的行为与这个规则不同——当SIGCHLD被忽略时,已经死去的子进程的资源使用值会被加到RUSAGE_CHILDREN的返回值中。
进程资源限制
每个进程都用一组资源限值,它们可以用来限制进程能够消耗的各种系统资源。如在执行任意一个程序之前如果不想让它消耗太多资源,则可以设置该进程的资源限制。使用shell的内置命令ulimit可以设置shell的资源限制(在C shell中是limit)。shell创建用来执行用户命令的进程会继承这些限制。
从2.6.24的内核开始,Linux特有的/proc/PID/limits文件可以用来查看任意进程的所有资源限制。这个文件由相应进程的真实用户ID所拥有,并且只有进程ID为用户ID的进程(或特权进程)才能够读取这个文件。
getrlimit()和setrlimit()系统调用允许一个进程读取和修改自己的资源限制。
#include <sys/resource.h>
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);
resource参数标识出了需读取或修改的资源限制。rlim参数用来返回限制值(getrlimit())或指定新的资源限制值((setrlimit()),它是一个指向包含两个字段的结构的指针。
struct rlimit
{
rlim_t rlim_cur;
rlim_t rlim_max;
};
这两个字段对应于一种资源的两个关联限制:软限制(rlim_cur)和硬限制(rlim_max)。(rlim_t数据类型是一个整数类型。)软限制规定了进程能够消耗的资源数量。一个进程可以将软限制调整为从0到硬限制之间的值。对于大多数资源来讲,硬限制的唯一作用是为软限制设定了上限。特权(CAP_SYS_RESOURCE)进程能够增大和缩小硬限制(只要其值仍然大于软限制),但非特权进程则只能缩小硬限制(这个行为是不可逆的)。在getrlimit()和setrlimit()调用中,rlim_cur和rlim_max取值为RLIM_INFINITY表示没有限制(不限制资源的使用)。
在大多数情况下,特权进程和非特权进程在使用资源时都会受到限制。通过fork()创建的子进程会继承这些限制并且在exec()调用之间不得到保持。
虽然资源限制是一个进程级别的特性,但在某些情况下,不仅需要度量一个进程对相关资源的消耗情况,还需要度量同一个真实用户ID下所有进程对资源的消耗总和情况。限制能创建的进程数目的RLIMIT_NPROC就较好地遵循了这个规则。仅仅将这个限制施加于进程本身所创建的子进程的数量的做法不是非常有效,因为由该进程创建的每个子进程都可以创建自己的子进程,而这些子进程还能够创建更多的子进程,以此类推。因此,这个限制是根据同一真实用户ID下所有的进程数来度量的。注意只有在设置了资源限制的进程中(即进程本身及继承了限制值的子孙进程)才会对资源使用情况进行检查。如果同一真实用户ID下存在一个没有设置限制(即限制值为无限)或设置了一个不同的限制值的进程,那么就会根据它所设置的限制值来检查其创建的子进程的数量。
下面在介绍每类资源的限制值时都会指出此类资源限制值是指同一真实用户ID下所有进程累积能够消耗的资源限制值。如果没有特别指出,那么一个资源限制值就是指进程本身能够消耗的资源限制值。
记住,在很多情况下,获取和设置资源限制的shell命令(bash和Korn shell中是ulimit,C shell中是limit)使用的单位与getrlimit()和setrlimit()使用的单位不同。如shell命令在限制各种内存段的大小时通常以千字节为单位。
getrlimit()和setrlimit()中的资源值
资 源 | 限 制 | SUSv3 |
---|---|---|
RLIMIT_AS | 进程虚拟内存限制大小(字节数) | ● |
RLIMIT_CORE | 核心文件大小(字节数) | ● |
RLIMIT_CPU | CPU时间(秒数) | ● |
RLIMIT_DATA | 进程数据段(字节数) | ● |
RLIMIT_FSIZE | 文件大小(字节数) | ● |
RLIMIT_MEMLOCK | 锁住的内存(字节数) | |
RLIMIT_MSGQUEUE | 为真实用户ID分配的POSIX消息队列的字节数(自Linux 2.6.8起) | |
RLIMIT_NICE | nice值(自Linux 2.6.12起) | |
RLIMIT_NOFILE | 最大的文件描述符数量加1 | ● |
RLIMIT_NPROC | 真实用户ID下的进程数量 | |
RLIMIT_RSS | 驻留集大小(字节数;没有实现) | |
RLIMIT_RTPRIO | 实时调度策略(自Linux 2.6.12起) | |
RLIMIT_RTTIME | 实时CPU时间(微秒;自Linux 2.6.25起) | |
RLIMIT_SIGPENDING | 真实用户ID信号队列中的信号数(自Linux 2.6.8起) | |
RLIMIT_STACK | 栈段的大小(字节数) | ● |
无法表示的限制值
在某些程序设计环境中,rlim_t数据类型可能无法表示某个特定资源限制的所有可取值,这是因为一个系统可能提供了多个程序设计环境,而在这些程序设计环境中rlim_t数据类型的大小是不同的。如当一个off_t为64位的大型文件编译环境被添加到off_t为32位的系统中时就会出现这种情况。(在每种环境中,rlim_t和off_t的大小是一样的。)这就会导致出现这样一种情况,即一个off_t为64位的程序能够创建一个子进程来执行一个rlim_t值较小的程序,这样子进程就会继承父进程的资源限制(如文件大小限制),但该资源限制超过了最大的rlim_t值。
为了帮助可移植应用程序处理可能出现的无法标识资源限制的情况,SUSv3规定了两个常量来标记无法表示的限制值:RLIM_SAVED_CUR和RLIM_SAVED_MAX。如果一个软资源限制无法用rlim_t表示,那么getrlimit()将会在rlim_cur字段返回RLIM_SAVED_CUR。而RLIM_SAVED_MAX的功能类似,即当碰到无法表示的硬限制时在rlim_max字段返回该值。
SUSv3允许实现在rlim_t能够表示资源限制的所有可取值时将RLIM_SAVED_CUR和RLIM_SAVED_MAX定义成与RLIM_INFINITY一样的值。在Linux上,这两个常量值就是这样定义的,这样rlim_t能够表示资源限制的所有可取值,但在像x86-32这样的32位架构上这种做法是不对的。在那些架构上,在一个大文件编译环境中,glibc将rlim_t定义为64位,但内核中表示资源限制的数据类型是unsigned long,它只有32位。当前版本的glibc是这样处理这种情况的:如果一个设置了_FILE_OFFSET_BITS=64编译选项的程序试图将一个资源限制值设置为一个超出32位unsigned long表示范围的值,那么glibc中setrlimit()的包装函数会毫无征兆地将这个值转换成RLIM_INFINITY。换句话说,要求完成的资源限制值的设置并没有如实地被完成。
由于在很多 x86-32 发行版中,处理文件的实用程序在编译时通常都设置了_FILE_OFFSET_BITS=64参数,因此当资源限制值超出32位的表示范围时系统不如实地设置资源限制值的做法不仅仅会影响到应用程序开发人员,还会影响到最终的用户。
有些人可能会认为glibc setrlimit()包装函数的做法要比在请求的资源限制超出32位unsigned long表示范围时返回一个错误要好,而这个问题的本质是内核的限制,glibc的开发人员在处理这个问题时则采用了前面正文中介绍的方法。
特定资源限制细节
TODO.
守护进程DAEMON
概述
daemon是一种具备下列特征的进程。
- 它的生命周期很长。通常,一个daemon会在系统启动的时候被创建并一直运行直至系统被关闭。
- 它在后台运行并且不拥有控制终端。控制终端的缺失确保了内核永远不会为daemon自动生成任何任务控制信号以及终端相关的信号(如SIGINT、SIGTSTP和SIGHUP)。
daemon是用来执行特殊任务的,如下面的示例所示。
cron:一个在规定时间执行命令的daemon。
sshd:安全shell daemon,允许在远程主机上使用一个安全的通信协议登录系统。
httpd:HTTP服务器daemon(Apache),它用于服务Web页面。
inetd:Internet超级服务器daemon,它监听从指定的TCP/IP端口上进入的网络连接并启动相应的服务器程序来处理这些连接。
很多标准的daemon会作为特权进程运行(即有效用户ID为0),因此在编写daemon程序时应该遵循下一章中给出的指南。
通常会将daemon程序的名称以字母d结尾(但并不是所有人都遵循这个惯例)。
在Linux上,特定的daemon会作为内核线程运行。实现此类daemon的代码是内核的一部分,它们通常在系统启动的时候被创建。当使用ps(1)列出线程时,这些daemon的名称会用方括号([])括起来。其中一个内核线程是pdflush,它会定期将脏页面(即高速缓冲区中的页面)写入磁盘。
创建一个daemon
要变成daemon,一个程序需要完成下面的步骤。
1. 执行一个fork(),之后父进程退出,子进程继续执行。(结果是daemon成为了init进程的子进程。)之所以要做这一步是因为下面两个原因。
- 假设daemon是从命令行启动的,父进程的终止会被shell发现,shell在发现之后会显示出另一个shell提示符并让子进程继续在后台运行。
- 子进程被确保不会成为一个进程组首进程,因为它从其父进程那里继承了进程组ID并且拥有了自己的唯一的进程ID,而这个进程ID与继承而来的进程组ID是不同的,这样才能够成功地执行下面一个步骤。
- 子进程调用setsid()开启一个新会话并释放它与控制终端之间的所有关联关系。
3. 如果daemon从来没有打开过终端设备,那么就无需担心daemon会重新请求一个控制终端了。如果daemon后面可能会打开一个终端设备,那么必须要采取措施来确保这个设备不会成为控制终端。这可以通过下面两种方式实现。
- 在所有可能应用到一个终端设备上的open()调用中指定O_NOCTTY标记。
- 或者更简单地说,在setsid()调用之后执行第二个fork(),然后再次让父进程退出并让孙子进程继续执行。这样就确保了子进程不会成为会话组长,因此根据System V中获取终端的规则(Linux也遵循了这个规则),进程永远不会重新请求一个控制终端。在遵循BSD规则的实现中,一个进程只能通过一个显式的ioctl() TIOCSCTTY操作来获取一个控制终端,因此第二个fork()调用对控制终端的获取并没有任何影响,但多一个fork()调用不会带来任何坏处。
- 清除进程的umask以确保当daemon创建文件和目录时拥有所需的权限。
5. 修改进程的当前工作目录,通常会改为根目录(/)。这样做是有必要的,因为daemon通常会一直运行直至系统关闭为止。如果daemon的当前工作目录为不包含/的文件系统,那么就无法卸载该文件系统。或者daemon可以将工作目录改为完成任务时所在的目录或在配置文件中定义的一个目录,只要包含这个目录的文件系统永远不会被卸载即可。如cron会将自身放在/var/spool/cron目录下。
6. 关闭daemon从其父进程继承而来的所有打开着的文件描述符。(daemon可能需要保持继承而来的文件描述的打开状态,因此这一步是可选的或者是可变更的。)之所以需要这样做的原因有很多。由于daemon失去了控制终端并且是在后台运行的,因此让daemon保持文件描述符0、1和2的打开状态毫无意义,因为它们指向的就是控制终端。此外,无法卸载长时间运行的daemon打开的文件所在的文件系统。因此,通常的做法是关闭所有无用的打开着的文件描述符,因为文件描述符是一种有限的资源。
7. 在关闭了文件描述符0、1和2之后,daemon通常会打开/dev/null并使用dup2()(或类似的函数)使所有这些描述符指向这个设备。之所以要这样做是因为下面两个原因。
- 它确保了当 daemon 调用了在这些描述符上执行 I/O 的库函数时不会出乎意料地失败。
- 它防止了daemon后面使用描述符1或2打开一个文件的情况,因为库函数会将这些描述符当做标准输出和标准错误来写入数据(进而破坏了原有的数据)。
/dev/null是一个虚拟设备,它总会将写入的数据丢弃。当需要删除一个shell命令的标准输出和错误时可以将它们重定向到这个文件。从这个设备中读取数据总是会返回文件结束的错误。
下面是becomeDaemon()函数的实现,它完成了上面描述的步骤以将调用者变成一个daemon。
#include<syslog.h>
int becomeDaemon(int flags);
becomeDaeomon()函数接收一个位掩码参数flags,它允许调用者有选择地执行其中的步骤,具体可参考程序清单中列出的头文件中的注释。
become_daemon.c的头文件
/* become_daemon.h
Header file for become_daemon.c.
*/
#ifndef BECOME_DAEMON_H /* Prevent double inclusion */
#define BECOME_DAEMON_H
/* Bit-mask values for 'flags' argument of becomeDaemon() */
#define BD_NO_CHDIR 01 /* Don't chdir("/") */
#define BD_NO_CLOSE_FILES 02 /* Don't close all open files */
#define BD_NO_REOPEN_STD_FDS 04 /* Don't reopen stdin, stdout, and
stderr to /dev/null */
#define BD_NO_UMASK0 010 /* Don't do a umask(0) */
#define BD_MAX_CLOSE 8192 /* Maximum file descriptors to close if
sysconf(_SC_OPEN_MAX) is indeterminate */
int becomeDaemon(int flags);
#endif
becomeDaemon()函数的实现。
/* become_daemon.c
A function encapsulating the steps in becoming a daemon.
*/
#include <sys/stat.h>
#include <fcntl.h>
#include "become_daemon.h"
#include "tlpi_hdr.h"
int /* Returns 0 on success, -1 on error */
becomeDaemon(int flags)
{
int maxfd, fd;
switch (fork()) { /* Become background process */
case -1: return -1;
case 0: break; /* Child falls through... */
default: _exit(EXIT_SUCCESS); /* while parent terminates */
}
if (setsid() == -1) /* Become leader of new session */
return -1;
switch (fork()) { /* Ensure we are not session leader */
case -1: return -1;
case 0: break;
default: _exit(EXIT_SUCCESS);
}
if (!(flags & BD_NO_UMASK0))
umask(0); /* Clear file mode creation mask */
if (!(flags & BD_NO_CHDIR))
chdir("/"); /* Change to root directory */
if (!(flags & BD_NO_CLOSE_FILES)) { /* Close all open files */
maxfd = sysconf(_SC_OPEN_MAX);
if (maxfd == -1) /* Limit is indeterminate... */
maxfd = BD_MAX_CLOSE; /* so take a guess */
for (fd = 0; fd < maxfd; fd++)
close(fd);
}
if (!(flags & BD_NO_REOPEN_STD_FDS)) {
close(STDIN_FILENO); /* Reopen standard fd's to /dev/null */
fd = open("/dev/null", O_RDWR);
if (fd != STDIN_FILENO) /* 'fd' should be 0 */
return -1;
if (dup2(STDIN_FILENO, STDOUT_FILENO) != STDOUT_FILENO)
return -1;
if (dup2(STDIN_FILENO, STDERR_FILENO) != STDERR_FILENO)
return -1;
}
return 0;
}
GNU C库提供了一个非标准的daemon()函数,它将调用者变成一个daemon。glibc daemon()函数与这里的becomeDaemon()函数不同,它并没有定义一个与flags参数等价的参数。
编写daemon指南
前面曾经提及过,一个daemon通常只有在系统关闭的时候才会终止。很多标准的daemon是通过在系统关闭时执行特定于应用程序的脚本来停止的。而那些不以这种方式终止的daemon会收到一个SIGTERM信号,因为在系统关闭的时候init进程会向所有其子进程发送这个信号。在默认情况下,SIGTERM信号会终止一个进程。如果daemon在终止之前需要做些清理工作,那么就需要为这个信号建立一个处理器。这个处理器必须能快速地完成清理工作,因为init在发完SIGTERM信号的5秒之后会发送一个SIGKILL信号。(这并不意味着这个daemon能够执行5秒的CPU时间,因为init会同时向系统中的所有进程发送信号,而它们可能都试图在5秒内完成清理工作。)
由于daemon是长时间运行的,因此要特别小心潜在的内存泄露问题和文件描述符泄露(即应用程序没有关闭所有打开着的文件描述符)。如果此类bug影响到了daemon的运行,那么唯一的解决方案是杀死它,之后(修复了bug)再重新启动它。
很多daemon需要确保同一时刻只有一个实例处于活跃状态。如让两个cron daemon都试图实行计划任务毫无意义。
使用SIGHUP重新初始化一个daemon
由于很多daemon需要持续运行,因此在设计daemon程序时需要克服一些障碍。
- 通常daemon会在启动时从相关的配置文件中读取操作参数,但有些时候需要在不重启daemon的情况下快速修改这些参数。
- 一些daemon会产生日志文件。如果daemon永远不关闭日志文件的话,那么日志文件就会无限制地增长,最终会阻塞文件系统。(在前面章节中曾经提到过即使删除了一个文件的文件名,只要有进程还打开着这个文件,那么这个文件就会一直存在下去。)这里需要有一种机制来告诉daemon关闭其日志文件并打开一个新文件,这样就能够在需要的时候旋转日志文件了。
解决这两个问题的方案是让daemon为SIGHUP建立一个处理器,并在收到这个信号时采取所需的措施。当控制进程与控制终端断开连接之后就会生成SIGHUP信号。由于daemon没有控制终端,因此内核永远不会向daemon发送这个信号。这样daemon就可以使用SIGHUP信号来达到目的。
TODO.
使用syslog记录消息和错误
在编写daemon时碰到的一个问题是如何显示错误消息。由于daemon是在后台运行的,因此通常无法像其他程序那样将消息输出到关联终端上。这个问题的一种解决方式是将消息写入到一个特定于应用程序的日志文件中。这种方式存在的一个主要问题是让系统管理员管理多个应用程序日志文件和监控其中是否存在错误消息比较困难,syslog工具就用于解决这个问题。
概述
syslog工具提供了一个集中式日志工具,系统中的所有应用程序都可以使用这个工具来记录日志消息。
syslog工具有两个主要组件:syslogd daemon和syslog(3)库函数。
System Log daemon syslogd从两个不同的源接收日志消息:一个是UNIX domain socket /dev/log,它保存本地产生的消息;另一个是Internet domain socket(UNP端口514,如果启用的话),它保存通过TCP/IP网络发送的消息。(在其他一些UNIX实现中,syslog socket位于/var/run/log。)
每条由syslogd处理的消息都具备几个特性,其中包括一个facility,它指定了产生消息的程序类型;还有一个是level,它指定了消息的严重程度(优先级)。syslogd daemon会检查每条消息的facility和level,然后根据一个相关配置文件/etc/syslog.conf中的指令将消息传递到几个可能目的地中的一个。可能的目的地包括终端或虚拟控制台、磁盘文件、FIFO、一个或多个(或所有)登录过的用户以及位于另一个系统上的通过TCP/IP网络连接的进程(通常是另一个syslogd daemon)。(将消息发送到另一个系统上的进程有助于通过将多个系统中的日志信息集中到一个位置以降低管理负担。)一条消息可以被发送到多个目的地(或不发送到任何目的地),具备不同的facility和level组合的消息可以被发送到不同的目的地或不同的目的地实例(即不同的控制台、不同的磁盘文件等)。
通过TCP/IP网络将syslog消息发送到另一个系统还有助于发现系统非法入侵。非法入侵通常会在系统日志中留下踪迹,但攻击者通常会删除日志记录以掩盖他们的行为。有了远程日志记录之后,攻击者就需要侵入另一个系统才能删除日志记录。
通常,任意进程都可以使用syslog(3)库函数来记录消息。这个函数会使用传入的参数以标准的格式构建一条消息,然后将这条消息写入/dev/log socket以供syslogd读取.
/dev/log中的消息的另一个来源是Kernel Log daemon klogd,它会收集内核日志消息(内核使用printk()函数生成的消息)。这些消息的收集可以通过两个等价的Linux特有的接口中的一个来完成(即/proc/kmsg文件和syslog(2)系统调用),然后使用syslog(3)库函数将它们写入/dev/log。
尽管syslog(2)和syslog(3)的名称相同,但它们执行的任务是不同的。glibc提供了一个调用syslog(2)的接口,其名称为klogctl()。
syslog工具原先出现在4.2BSD中,但现在几乎所有的UNIX实现都提供了这个工具。SUSv3对syslog(3)和相关函数进行了标准化,但并没有规定syslogd的实现和操作以及syslog.conf文件的格式。Linux中syslogd的实现与它原先在BSD的实现的不同之处在于Linux允许对在syslog.conf中指定的消息处理规则进行一些扩展。
syslog API
syslog API由以下三个主要函数构成。
- openlog()函数为后续的的syslog()调用建立了默认设置。syslog()的调用是可选的,如果省略了这个调用,那么就会使用首次调用syslog()时采用的默认设置来建立到日志记录工具的连接。
- syslog()函数记录一条日志消息。
- 当完成日志记录消息之后需要调用closelog()函数拆除与日志之间的连接。
所有这些函数都不会返回一个状态值,这是因为系统日志服务应该总是处于可用状态(系统管理员应该在服务不可用时立即能发现这个问题)。此外,如果在系统记录日志的过程中发生了一个错误,应用程序通常也无法做更多的事情来报告这个错误。
建立一个到系统日志的连接
openlog()函数的调用是可选的,它建立一个到系统日志工具的连接并为后续的syslog()调用设置默认设置。
#include<syslog.h>
void openlog(const char *ident, int option, int facility);
ident参数是一个指向字符串的指针,syslog()输出的每条消息都会包含这个字符串,这个参数的取值通常是程序名。注意openlog()仅仅是复制了这个指针的值。只要应用程序后面会继续调用syslog(),那么就应该确保不会修改所引用的字符串。
如果ident的值为NULL,那么与其他一些实现一样,glibc syslog实现会自动将程序名作为ident的值。但SUSv3并没有要求实现这个功能,一些实现也没有提供这个功能。可移植的应用程不应该依赖于这个功能。
传入openlog()的log_options参数是一个位掩码,它是下面几个常量之间的OR值。
LOG_CONS
当向系统日志发送消息发生错误时将消息写入到系统控制台(/dev/console)。
LOG_NDELAY
立即打开到日志系统的连接(即底层的UNIX domain socket, /dev/log)。在默认情况下(LOG_ODELAY),只有在首次使用syslog()记录消息的时候才会打开连接。O_NDELAY标记对于那些需要精确控制何时为/dev/log分配文件描述符的程序来讲是比较有用的,如调用chroot()的程序就有这样的要求。在调用chroot()之后,/dev/log路径名将不再可见,因此在chroot()之前需要调用一个指定了LOG_NDELAY的openlog()。tftpd daemon(Trivial File Transfer)就因为上述的原因而使用了LOG_NDELAY。
LOG_NOWAIT
不要wait()被创建来记录日志消息的子进程。在那些创建子进程来记录日志消息的实现上,当调用者创建并等待子进程时就需要使用LOG_NOWAIT了,这样syslog()就不会试图等待已经被调用者销毁的子进程。在Linux上,LOG_NOWAIT不起任何作用,因为在记录日志消息时不会创建子进程。
LOG_ODELAY
这个标记的作用与LOG_NDELAY相反——连接到日志系统的操作会被延迟至记录第一条消息时。这是默认行为,因此无需指定这个标记。
LOG_PERROR
将消息写入标准错误和系统日志。通常,daemon进程会关闭标准错误或将其重定向到/dev/null,这样LOG_PERROR就没有用了。
LOG_PID
在每条消息中加上调用者的进程ID。在一个创建多个子进程的服务器中使用LOG_PID有助于区分哪个进程记录了某条特定的消息。
openlog()的facility值和syslog()的priority参数:
值 | 描 述 | SUSv3 |
---|---|---|
LOG_AUTH | 安全和验证消息(如su) | ● |
LOG_AUTHPRIV | 私有的安全和验证消息 | |
LOG_CRON | 来自cron和at daemons的消息 | ● |
LOG_DAEMON | 来自其他系统daemon的消息 | ● |
LOG_FTP | 来自ftp daemon的消息(ftpd) | |
LOG_KERN | 内核消息(用户进程无法生成此类消息) | ● |
LOG_LOCAL0 | 保留给本地使用(包括LOG_LOCAL1到LOG_LOCAL7) | ● |
LOG_LPR | 来自行打印机系统的消息(lpr、lpd、lpc) | ● |
LOG_MAIL | 来自邮件系统的消息 | ● |
LOG_NEWS | 与Usenet网络新闻相关的消息 | ● |
LOG_SYSLOG | 来自syslogd daemon的消息 | |
LOG_USER | 用户进程(默认值)生成的消息 | ● |
LOG_UUCP | 来自UUCP系统的消息 | ● |
上表列出的facility值的大部分都在SUSv3中进行了定义,如表中的SUSv3列所示,但LOG_AUTHPRIV和LOG_FTP只出现在了一些UNIX实现中,LOG_SYSLOG则在大多数实现中都存在。当需要将包含密码或其他敏感信息的日志消息记录到一个与LOG_AUTH指定的位置不同的位置上时,LOG_AUTHPRIV值是比较有用的。
LOG_KERN facility值用于内核消息。用户空间的程序是无法用这个工具记录日志消息的。LOG_KERN常量的值为0。如果在syslog()调用中使用了这个常量,那么0被翻译成了“使用默认的级别”。
记录一条日志消息
要写入一条日志消息可以调用syslog()。
#include<syslog.h>
void syslog(int priority, char*format,……);
priority参数是facility值和level值的OR值。facility表示记录日志消息的应用程序的类别,其取值为上表中列出的值中的一个。如果省略了这个参数,那么facility的默认值为前面一个openlog()调用中指定的facility值,或者当那个调用中也省略了facility值的话为LOG_USER。level表示消息的严重程度,其取值为表37-2中列出的值中的一个。这张表中列出的所有值都在SUSv3进行了定义。
syslog()中priority参数的level值(严重性从最高到最低):
值 | 描 述cc |
---|---|
LOG_EMERG | 紧急或令人恐慌的情况(系统不可用了) |
LOG_ALERT | 需要立即处理的情况(如破坏了系统数据库) |
LOG_CRIT | 关键情况(如磁盘设备发生错误) |
LOG_ERR | 常规错误情况 |
LOG_WARNING | 警告 |
LOG_NOTICE | 可能需要特殊处理的普通情况 |
LOG_INFO | 情报性消息 |
LOG_DEBUG | 调试消息 |
另一个传入syslog()的参数是一个格式字符串以及相应的参数,它们与传入printf()中的参数是一样的,但与printf()不同的是这里的格式字符串不需要包含一个换行字符。此外,格式字符串还可以包含双字符序列%m,在调用的时候这个序列会被与当前的errno值对应的错误字符串(即等价于strerror(errno))所替换。
下面的代码演示了openlog()和syslog()的用法。
openlog(argv[0],LOG_PID | LOG_CONS | LOG_NOWAIT, LOG_LOCALO);
syslog(LOG_ERROR,"Bad argument: %s",argv[1]);
syslog(LOG_USER | LOG_INFO, "Exiting");
由于在第一个syslog()调用中并没有指定facility,因此将会使用openlog()调用中的默认值(LOG_LOCAL0)。在第二个syslog()调用中显式地指定了LOG_USER标记来覆盖openlog()调用中设置的默认值。
在shell中可以使用logger(1)命令来向系统日志中添加条目。这个命令允许指定与日志消息相关的level(priority)和ident(tag),更多细节可参考logger(1)手册。SUSv3规定了logger命令(并没有进行全面定义),大多数UNIX实现都实现了这个命令。
像下面这样使用syslog()写入一些用户提供的字符串是错误的。
syslog(priority,user_supplied_string);
上面这段代码存在的问题是应用程序会面临所谓的格式字符串攻击。如果用户提供的字符串中包含格式指示符(如%s),那么结果将是不可预测的,从安全的角度来讲,这种结果可能是具有破坏性的。(这个结论也同样适用于传统的printf()函数。)因此需要将上面的调用重写为下面这样。
syslog(priority,"%s",user_supplied_string);
关闭日志
当完成日志记录之后可以调用closelog()来释放分配给/dev/log socket的文件描述符。
#include<syslog.h>
void closelog(void);
由于 daemon 通常会持续保持与系统日志之间的连接的打开状态,因此通常会省略对closelog()的调用。
过滤日志消息
setlogmask()函数设置了一个能过滤由syslog()写入的消息的掩码。
#include<syslog.h>
int setlogmask(int mask_priority);
所有level不在当前的掩码设置中的消息都会被丢弃。默认的掩码值允许记录所有的严重性级别。
宏LOG_MASK()(在<syslog.h>中定义)会将level值转换成适合传入setlogmask()的位值。如要丢弃除优先级为LOG_ERR以及以上之外的消息时可以使用下面的调用。
setlogmask(LOG_MASK(LOG_EMERG) | LOG_MASK(LOG_ALERT) | LOG_MASK(LOG_CRIT) | LOG_MASK(LOG_ERR));
SUSv3规定了LOG_MASK()宏。大多数UNIX实现(包括Linux)还提供了标准中未规定的LOG_UPTO()宏。它创建一个能过滤特定级别以及以上的所有消息的位掩码。使用这个宏能够将前面的setlogmask()调用简化成下面这个。
setlogmask(LOG_UPTO(LOG_ERR));
/etc/syslog.conf文件
/etc/syslog.conf配置文件控制syslogd daemon的操作。这个文件由规则和注释(以#字符打头)构成。规则的形式如下所示。
facility.level action
facility和level组合在一起被称为选择器,因为它们选择了需应用规则的消息。action指定了与选择器匹配的消息被发送到何处。选择器和action之间用空白字符隔开,下面是一些示例。
*.err /dev/tty10
auth.notice root
*.debug;mail.none;news.none -/var/log/messages
第一条规则表示来自所有工具(*)的level为err((LOG_ERR)或更高的消息应该被发送到/dev/tty10控制台设备上。第二条规则表示来自验证工具(LOG_AUTH)的level为notice(LOG_NOTICE)或更高的消息应该被发送到root登录的所有控制台和终端。如这个特别的规则允许一个登录的root用户立即看到失败的su尝试。
最后一条规则演示了规则语法中的几个高级特性。一个规则可以包含多个选择器,选择器之间用分号隔开。第一个选择器指定了所有的消息,它使用通配符表示facility并将level的值指定为debug,这意味着所有级别为debug(最低的级别)以及更高的消息都会被记录下来。(在Linux以及其他一些UNIX实现中,可以将level指定为,其含义与debug是一样的。但不是所有的syslog实现都支持这个特性。)通常,一个包含多个选择器的规则会匹配与其中任意一个选择器对应的消息,但当将level设置为none时则表示排除所有属于相应的facility的消息。因此这条规则将除来自mail和news工具的消息之外的所有消息发送到/var/log/messages文件中。文件名前面的连接符(-)表示无需每次写入文件时都将文件同步到磁盘(参见13.3节)。这意味着写入操作将变得更快,但如果系统在写入之后崩溃的话可能会丢失一些数据。
每次修改syslog.conf文件之后都需要使用下面的方式让daemon根据这个文件重新初始化自身。
$ killall -HUP syslogd
syslog.conf规则语法的高级特性允许编写比前面介绍的更加强大的规则,更多细节可参考syslog.conf(5)手册。
编写安全的特权程序
特权程序能够访问普通用户无法访问的特性和资源(文件设备等)。一个程序可以通过下面两种方式以特权方式运行。
- 程序在一个特权用户ID下启动,很多daemon和网络服务器通常以root身份运行,它们就属于这种类别。
- 程序设置了set-user-ID或set-group-ID权限位。当一个set-user-ID(set-group-ID)程序被执行之后,它会将进程的有效用户(组)ID修改为与程序文件的所有者(组)一样的ID。(在9.3节中首次对set-user-ID和set-group-ID程序进行了介绍。)在本章中有时候会使用术语set-user-ID-root区分将超级用户权限赋给进程的set-user-ID程序与赋给进程另一个有效身份的程序。
如果一个特权程序包含bug或可以被恶意用户破坏,那么系统或应用程序的安全性就会受到影响。从安全的角度来讲,在编写程序的时候应该将系统受到安全威胁的可能性以及受到安全威胁时产生的损失降到最小。
是否需要一个Set-User-ID或Set-Group-ID程序
有关编写set-user-ID和set-group-ID程序的最佳建议中的一条就是尽量避免编写这种程序。在执行一个任务时如果存在无需赋给程序权限的方法,那么一般来讲应该采用这种方法,因为这样就消除了发生安全性问题的可能。
有时候可以将需要权限才能完成的功能拆分到一个只执行单个任务的程序中,然后在需要的时候在子进程中执行这个程序。对于库来讲,这项技术是特别有用的。
TODO.
能力
TODO.
登录记账
TODO.
共享库基础
共享库是一种将库函数打包成一个单元使之能够在运行时被多个进程共享的技术。这种技术能够节省磁盘空间和RAM。
目标库
构建程序的一种方式是简单地将每一个源文件编译成目标文件,然后将这些目标文件链接在一起组成一个可执行程序,如下所示。
$ cc -g -c prog.c mod1.c mod2.c mod3.c
$ cc -g -o prog_nolib prog.o mod1.o mod2.o mod3.o
链接实际上是由一个单独的链接器程序ld来完成的。当使用cc(或gcc)命令链接一个程序时,编译器会在幕后调用ld。在Linux上应该总是通过gcc间接地调用链接器,因为gcc能够确保使用正确的选项来调用ld并将程序与正确的库文件链接起来。
在很多情况下,源代码文件也可以被多个程序共享。因此要降低工作量的第一步就是将这些源代码文件只编译一次,然后在需要的时候将它们链接进不同的可执行文件中。虽然这项技术能够节省编译时间,但其缺点是在链接的时候仍然需要为所有目标文件命名。此外,大量的目标文件会散落在系统上的各个目录中,从而造成目录中内容的混乱。
为解决这个问题,可以将一组目标文件组织成一个被称为对象库的单元。对象库分为两种:静态的和共享的。共享库是一种更加现代化的对象库,它比静态库更具优势.
题外话:在编译程序时包含调试器信息
在上面的cc命令中使用了-g选项以在编译过的程序中包含调试信息。一般来讲,创建允许调试的程序和库是一种比较好的做法。(在早期,有时候会忽略调试信息,这样产生的可执行文件会占用更少的磁盘和RAM,但现在磁盘和RAM已经非常便宜了。)
此外,在一些架构上,如x86-32,不应该指定–fomit–frame–pointer选项,因为这会使得无法调试。(在一些架构上,如x86-64,这个选项是默认启用的,因为它不会防止调试。)出于同样的原因,可执行文件和库不应该使用strip(1)删除调试信息。
静态库
在开始讨论共享库之前首先对静态库作一个简短的介绍,这样读者就能够弄清楚共享库与静态库之间的差别以及共享库所具备的优势了。
静态库也被称为归档文件,它是UNIX系统提供的第一种库。静态库能带来下列好处。
- 可以将一组经常被用到的目标文件组织进单个库文件,这样就可以使用它来构建多个可执行程序并且在构建各个应用程序的时候无需重新编译原来的源代码文件。
- 链接命令变得更加简单了。在链接命令行中只需要指定静态库的名称即可,而无需一个个地列出目标文件了。链接器知道如何搜素静态库并将可执行程序需要的对象抽取出来。
创建和维护静态库
从结果上来看,静态库实际上就是一个保存所有被添加到其中的目标文件的副本的文件。这个归档文件还记录着每个目标文件的各种特性,包括文件权限、数字用户和组ID以及最后修改时间。根据惯例,静态库的名称的形式为libname.a。
使用ar(1)命令能够创建和维护静态库,其通用形式如下所示。
$ ar options archive object-file...
options参数由一系列的字母构成,其中一个是操作代码,其他是能够影响操作的执行的修饰符。下面是一些常用的操作代码。
1. r(替换):
将一个目标文件插入到归档文件中并取代同名的目标文件。这个创建和更新归档文件的标准方法,使用下面的命令可以构建一个归档文件。
$ cc -g -c mod1.c mod2.c mod3.c
$ ar r libdemo.a mod1.o mod2.o mod3.o
$ rm mod1.o mod2.o mod3.o
从上面可以看出,在构建完库之后可以根据需要删除原始的目标文件,因为已经不再需要它们了。
2. t(目录表):
显示归档中的目录表。在默认情况下只会列出归档文件中目标文件的名称。添加v(verbose)修饰符之后可以看到记录在归档文件中的各个目标文件的其他所有特性,如下面的例子所示。
$ ar tv libdemo.a
rw-r--r-- 0/0 4872 Jan 1 08:00 1970 mod1.o
rw-r--r-- 0/0 4872 Jan 1 08:00 1970 mod2.o
rw-r--r-- 0/0 4872 Jan 1 08:00 1970 mod3.o
从左至右每个目标文件的特性为被添加到归档文件中时的权限、用户ID和组ID、大小以及上次修改的日志和时间。
3. d(删除):
从归档文件中删除一个模块,如下面的例子所示。
$ ar d libdemo.a mod3.o
使用静态库
将程序与静态库链接起来存在两种方式。第一种是在链接命令中指定静态库的名称,如下所示。
$ cc -g -c prog.c
$ cc -g -o prog prog.o libdemo.a
或者将静态库放在链接器搜索的其中一个标准目录中(如/usr/lib),然后使用-l选项指定库名(即库的文件名去除了lib前缀和.a后缀)。
$ cc -g -o prog prog.o -ldemo
如果库不位于链接器搜索的目录中,那么可以只用-L选项指定链接器应该搜索这个额外的目录。
$ cc -g -o prog prog.o -Lmylibdir -ldemo
虽然一个静态库可以包含很多目标模块,但链接器只会包含那些程序需要的模块。
共享库概述
将程序与静态库链接起来时(或没有使用静态库),得到的可执行文件会包含所有被链接进程序的目标文件的副本。这样当几个不同的可执行程序使用了同样的目标模块时,每个可执行程序会拥有自己的目标模块的副本。这种代码的冗余存在几个缺点。
- 存储同一个目标模块的多个副本会浪费磁盘空间,并且所浪费的空间是比较大的。
- 如果几个使用了同一模块的程序在同一时刻运行,那么每个程序会独立地在虚拟内存中保存一份目标模块的副本,从而提高系统中虚拟内存的整体使用量。
- 如果需要修改一个静态库中的一个目标模块(可能是因为安全性或需要修正bug),那么所有使用那个模块的可执行文件都必须要重新进行链接以合并这个变更。这个缺点还会导致系统管理员需要弄清楚哪些应用程序链接了这个库。
共享库就是设计用来解决这些缺点的。共享库的关键思想是目标模块的单个副本由所有需要这些模块的程序共享。目标模块不会被复制到链接过的可执行文件中,相反,当第一个需要共享库中的模块的程序启动时,库的单个副本就会在运行时被加载进内存。当后面使用同一共享库的其他程序启动时,它们会使用已经被加载进内存的库的副本。使用共享库意味着可执行程序需要的磁盘空间和虚拟内存(在运行的时候)更少了。
虽然共享库的代码是由多个进程共享的,但其中的变量却不是的。每个使用库的进程会拥有自己的在库中定义的全局和静态变量的副本。
共享库还具备下列优势。
- 由于整个程序的大小变得更小了,因此在一些情况下,程序可以完全被加载进内存中,从而能够更快地启动程序。这一点只有在大型共享库正在被其他程序使用的情况下才成立。第一个加载共享库的程序实际上在启动时会花费更长的时间,因为必须要先找到共享库并将其加载到内存中。
- 由于目标模块没有被复制进可执行文件中,而是在共享库中集中维护的,因此在修改目标模块时无需重新链接程序就能够看到变更,甚至在运行着的程序正在使用共享库的现有版本的时候也能够进行这样的变更。
这项新增功能的主要开销如下所述。
- 在概念上以及创建共享库和构建使用共享库的程序的实践上,共享库比静态库更复杂。
- 共享库在编译时必须要使用位置独立的代码,这在大多数架构上都会带来性能开销,因为它需要使用额外的一个寄存器([Hubicka, 2003])。
- 在运行时必须要执行符号重定位。在符号重定位期间,需要将对共享库中每个符号(变量或函数)的引用修改成符号在虚拟内存中的实际运行时位置。由于存在这个重定位的过程,与静态链接程序相比,一个使用共享库的程序或多或少需要花费一些时间来执行这个过程。
共享库的另一种用法是作为Java NativeInterface (JNI)中的一个构建块,它允许Java代码通过调用共享库中的C函数直接访问底层操作系统的特性.
创建和使用共享库——首回合
为了理解共享库的操作方式,下面开始介绍构建和使用一个共享库所需完成的最少步骤,在介绍的过程中会忽略平时使用的共享库文件命名规范。允许程序自动加载它们所需的共享库的最新版本,同时也允许一个库的多个相互不兼容的版本(所谓的主版本)和谐地共存。
在本章中,我们只关心Executable and Linking Format(ELF)共享库,因为现代版本的Linux以及很多其他UNIX实现的可执行文件和共享库都采用了ELF格式。ELF取代了较早以前的a.out和COFF格式。
创建一个共享库
为构建之前创建的静态库的共享版本,需要执行下面的步骤。
$ gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
$ gcc -g -shared -o libfoo.so mod1.o mod2.o mod3.o
第一个命令创建了三个将要被放到库中的目标模块。(下一节将对cc –fPIC选项进行解释。)cc –shared命令创建了一个包含这三个目标模块的共享库。
根据惯例,共享库的前缀为lib,后缀为.so(表示shared object)。
在上面的例子中使用了gcc命令,而并没有使用与之等价的cc命令,这是为了突出用来创建共享库的命令行选项是依赖于编译器的,在另一个UNIX实现上使用一个不同的C编译器可能会需要使用不同的选项。
注意可以将编译源代码文件和创建共享库放在一个命令中执行。
$ gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c -shared -o libfoo.so
与静态库不同,可以向之前构建的共享库中添加单个目标模块,也可以从中删除单个目标模块。与普通的可执行文件一样,共享库中的目标文件不再维护不同的身份。
位置独立的代码
cc-fPIC选项指定编译器应该生成位置独立的代码,这会改变编译器生成执行特定操作的代码的方式,包括访问全局、静态和外部变量,访问字符串常量,以及获取函数的地址。这些变更使得代码可以在运行时被放置在任意一个虚拟地址处。这一点对于共享库来讲是必需的,因为在链接的时候是无法知道共享库代码位于内存的何处的。(一个共享库在运行时所处的内存位置依赖于很多因素,如加载这个库的程序已经占用的内存数量和这个程序已经加载的其他共享库。)
cc-fPIC选项指定编译器应该生成位置独立的代码,这会改变编译器生成执行特定操作的代码的方式,包括访问全局、静态和外部变量,访问字符串常量,以及获取函数的地址。这些变更使得代码可以在运行时被放置在任意一个虚拟地址处。这一点对于共享库来讲是必需的,因为在链接的时候是无法知道共享库代码位于内存的何处的。(一个共享库在运行时所处的内存位置依赖于很多因素,如加载这个库的程序已经占用的内存数量和这个程序已经加载的其他共享库。)
在Linux/x86-32上,可以使用不加–fPIC选项编译的模块来创建共享库。但这样做的话会丢失共享库的一些优点,因为包含依赖于位置的内存引用的程序文本页面不会在进程间共享。在一些架构上是无法在不加–fPIC选项的情况下构建共享库的。
为了确定一个既有目标文件在编译时是否使用了–fPIC选项,可以使用下面两个命令中的一个来检查目标文件符号表中是否存在名称_GLOBAL_OFFSET_TABLE_。
$ nm mod1.o | grep _GLOBAL_OFFSET_TABLE_
$ readelf -s mod1.o | grep _GLOBAL_OFFSET_TABLE_
相应地,如果下面两个相互等价的命令中的任意一个产生了任何输出,那么指定的共享库中至少存在一个目标模块在编译时没有指定–fPIC选项。
$ objdump --all-headers libfoo.so | grep TEXTREL
$ readelf -d libfoo.so | grep TEXTREL
字符串TEXTREL表示存在一个目标模块,其文本段中包含需要运行时重定位的引用。
使用一个共享库
为了使用一个共享库就需要做两件事情,而使用静态库的程序则无需完成这两件事情。
- 由于可执行文件不再包含它所需的目标文件的副本,因此它必须要通过某种机制找出在运行时所需的共享库。这是通过在链接阶段将共享库的名称嵌入可执行文件中来完成的。(在ELF中,库依赖性是记录在可执行文件的DT_NEEDED标签中的。)一个程序所依赖的所有共享库列表被称为程序的动态依赖列表。
- 在运行时必须要存在某种机制来解析嵌入的库名——即找出与在可执行文件中指定的名称对应的共享库文件——接着如果库不在内存中的话就将库加载进内存。
将程序与共享库链接起来时自动会将库的名字嵌入可执行文件中。
$ gcc -g -Wall -o -prog prog.c libfoo.so
如果现在运行这个程序,那么就会收到下面的错误消息。
$ ./prog
gcc: error: libfoo.so: No such file or directory
解决这个问题就需要做第二件事情:动态链接,即在运行时解析内嵌的库名。这个任务是由动态链接器(也称为动态链接加载器或运行时链接器)来完成的。动态链接器本身也是一个共享库,其名称为/lib/ld-linux.so.2,所有使用共享库的ELF可执行文件都会用到这个共享库。
路径名/lib/ld-linux.so.2通常是一个指向动态链接器可执行文件的符号链接。这个文件的名称为ld-version.so,其中version表示安装在系统上的glibc的版本——如ld-2.11.so。在一些架构上,动态链接器的路径名是不同的。如在IA-64上,动态链接器符号链接的名称为/lib/ld-linux-ia64.so.2。
动态链接器会检查程序所需的共享库清单并使用一组预先定义好的规则来在文件系统上找出相关的库文件。其中一些规则指定了一组存放共享库的标准目录。如很多共享库位于/lib和/usr/lib中。之所以出现上面的错误消息是因为程序所需的库位于当前工作目录中,而不位于动态链接器搜索的标准目录清单中。
一些架构(如zSeries、PowerPC64以及x86-64)同时支持执行32位和64位的程序。在此类系统上,32位的库位于/lib子目录中,64位的库位于/lib64子目录中。
LD_LIBRARY_PATH环境变量
通知动态链接器一个共享库位于一个非标准目录中的一种方法是将该目录添加到LD_LIBRARY_PATH环境变量中以分号分隔的目录列表中。(也可以使用分号来分隔,在使用分号时必须将列表放在引号中以防止shell将分号解释了其他用途。)如果定义了LD_LIBRARY_PATH,那么动态链接器在查找标准库目录之前会先查找该环境变量列出的目录中的共享库。(稍后会介绍一个生产应用程序永远都不应该依赖于LD_LIBRARY_PATH,但此刻通过这个变量可以方便地开始使用共享库了。)因此可以使用下面的命令来运行程序。
$ LD_LIBRARY_PATH=. ./prog
Called mod1-x1
Called mod2-x2
上面的命令中使用的shell(bash、Korn以及Bourne)语法在执行prog的进程中创建了一个环境变量定义。这个定义告诉动态链接器在.,即当前工作目录中搜索共享库。
在LD_LIBRARY_PATH列表中的空目录(如dirx::diry中间的空目录)等价于.,即当前工作目录(但注意将LD_LIBRARY_PATH的值设置为空字符串并不能达到同样效果)。需要避免这种用法(SUSv3同样不建议在PATH环境变量中使用这种方式)。
静态链接和动态链接比较
通常,术语链接用来表示使用链接器ld将一个或多个编译过的目标文件组合成一个可执行文件。有时候会使用术语静态链接从动态链接中将在运行时加载可执行文件所需的共享库这一步骤给区分出来。(静态链接有时候也被称为链接编辑,像ld这样的静态链接器有时候被称为链接编辑器。)每个程序——包括那些使用共享库的程序——都会经历一个静态链接的阶段。在运行时,使用共享库的程序会经历额外的动态链接阶段。
共享库soname
到目前为止介绍的所有例子中,嵌入到可执行文件以及动态链接器在运行时搜索的名称是共享库文件的实际名称,这被称为库的真实名称(real name)。但可以——实际上经常这样做——使用别名来创建共享库,这种别名称为soname(ELF中的DT_SONAME标签)。
如果共享库拥有一个soname,那么在静态链接阶段会将soname嵌入到可执行文件中,而不会使用真实名称,同时后面的动态链接器在运行时也会使用这个soname来搜索库。引入soname的目的是为了提供一层间接,使得可执行程序能够在运行时使用与链接时使用的库不同的(但兼容的)共享库。
使用soname的第一步是在创建共享库时指定soname。
$ gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
$ gcc -g -shared -Wl,-soname,libbar.so -o libfoo.so mod1.o mod2.o mod3.o
–Wl、–soname以及libbar.so选项是传给链接器的指令以将共享库libfoo.so的soname设置为libbar.so。
如果要确定一个既有共享库的soname,那么可以使用下面两个命令中的任意一个。
$ objdump -p libfoo.so | grep SONAME
SONAME libbar.so
$ readelf -d libfoo.so | grep SONAME
0x0000000e (SONAME) Library soname:[libbar.so]
在使用soname创建了一个共享库之后就可以照常创建可执行文件了。
$ gcc -g -Wall -o prog.c libfoo.so
但这次链接器检查到库libfoo.so包含了soname libbar.so,于是将这个soname嵌入到了可执行文件中。
现在当运行这个程序时就会看到下面的输出。
gcc: error: libbar.so: No such file or directory
这里的问题是动态链接器无法找到名为libbar.so共享库。当使用soname时还需要做一件事情:必须要创建一个符号链接将soname指向库的真实名称,并且必须要将这个符号链接放在动态链接器搜索的其中一个目录中。因此可以像下面这样运行这个程序。
$ ln -s libfoo.so libbar.so
$ LD_LIBRARY_PATH=. ./prog
Called mod1-x1
Called mod2-x2
下图给出了在使用一个内嵌的soname,将程序与共享库链接起来,以及创建运行程序所需的soname符号链接时所涉及到的编译和链接事项。
下图给出了当图41-1中创建的程序被加载进内存以备执行时发生的事情。
要找出一个进程当前使用的共享库则可以列出相应的Linux特有的/proc/PID/maps文件中的内容。
使用共享库的有用工具
本节将简要介绍对分析共享库、可执行文件以及编译过的目标文件(.o)有用的一组工具。
ldd命令
ldd(1)(列出动态依赖)命令显示了一个程序运行所需的共享库,如下所示。
$ ldd prog
libdemo.so.1 => /usr/lib/libdemo.so.1(0x40019000)
ldd命令会解析出每个库引用(使用的搜索方式与动态链接器一样)并以下面的形式显示结果。
library-name => resolves-to-path
对于大多数ELF可执行文件来讲,ldd至少会列出与ld-linux.so.2、动态链接器以及标准C库libc.so.6相关的条目。
在一些架构上,C库的名称是不同的。如在IA-64和Alpha上,这个库的名称是libc.so.6.1。
objdump和readelf命令
objdump命令能够用来获取各类信息——包括反汇编的二进制机器码——从一个可执行文件、编译过的目标以及共享库中。它还能够用来显示这些文件中各个ELF节的头部信息,当这样使用objdump时它就类似于readelf,readelf能显示类似的信息,但显示格式不同。
nm命令
nm命令会列出目标库或可执行程序中定义的一组符号。这个命令的一种用途是找出哪些库定义了一个符号。如要找出哪个库定义了crypt()函数则可以像下面这样做。
$ nm -A /usr/lib/lib*.so 2> /dev/null | grep 'crypt$'
/usr/lib/libcrypt.so:00007080 W crypt
nm的–A选项指定了在显示符号的每一行的开头处应该列出库的名称。这样做是有必要的,因为在默认情况下,nm只列出库名一次,然后在后面会列出库中包含的所有符号,这对于像上面那样进行某种过滤的例子来讲是没有用处的。此外,这里还丢弃了标准错误输出以便隐藏与nm命令无法识别文件格式有关的错误消息。从上面的输出中可以看出,crypt()被定义在了libcrypt库中。
共享库版本和命名规则
下面考虑在共享库的版本化过程中需要做的事情。一般来讲,一个共享库相互连续的两个版本是相互兼容的,这意味着每个模块中的函数对外呈现出来的调用接口是一致的,并且函数的语义是等价的(即它们能取得同样的结果)。这种版本号不同但相互兼容的版本被称为共享库的次要版本。但有时候需要创建创建一个库的新主版本——即与上一个版本不兼容的版本。同时,必须要确保使用老版本的库的程序仍然能够运行。为了满足这些版本化的要求,共享库的真实名称和soname必须要使用一种标准的命名规范。
真实名称、soname以及链接器名称
共享库的每个不兼容版本是通过一个唯一的主要版本标识符来区分的,这个主要版本标识符是共享库的真实名称的一部分。根据惯例,主要版本标识符由一个数字构成,这个数字随着库的每个不兼容版本的发布而顺序递增。除了主要版本标识符之外,真实名称还包含一个次要版本标识符,它用来区分库的主要版本中兼容的次要版本。真实名称的格式规范为libname.so.major-id.minor-id。
与主要版本标识符一样,次要版本标识符可以是任意字符串。但根据惯例,它要么是一个数字,要么是两个由点分隔的数字,其中第一个数字标识出了次要版本,第二个数字表示该次要版本中的补丁号或修订号。下面是一些共享库的真实名称。
libdemo.so.1.0.1
libdemo.so.1.0.2
libdemo.so.2.0.0
libreadline.so.5.0
共享库的soname包括相应的真实名称中的主要版本标识符,但不包含次要版本标识符。因此soname的形式为libname.so.major-id。
通常,会将soname创建为包含真实名称的目录中的一个相对符号链接。下面是一些soname的例子以及它们可能通过符号链接指向的真实名称。
libdemo.so.1 -> libdemo.so.1.0.2
libdemo.so.2 -> libdemo.so.2.0.0
libreadline.so.5 -> libreadline.so.5.0
对于共享库的某个特定的主要版本来讲,可能存在几个库文件,这些库文件是通过不同的次要版本标识符来区分的。通常,每个库的主要版本的soname会指向在主要版本中最新的次要版本。这种配置使得在共享库的运行时操作期间版本化语义能够正确工作。由于静态链接阶段会将soname的副本(独立于次要版本)嵌入到可执行文件中并且soname符号链接后面可能会被修改指向一个更新的(次要)版本的共享库,因此可以确保可执行文件在运行时能够加载库的最新的次要版本。此外,由于一个库的不同的主要版本的soname不同,因此它们能够和平地共存并且被需要它们的程序访问。
除了真实名称和soname之外,通常还会为每个共享库定义第三个名称:链接器名称,将可执行文件与共享库链接起来时会用到这个名称。链接器名称是一个只包含库名同时不包含主要或次要版本标识符的符号链接,因此其形式为libname.so。有了链接器名称之后就可以构建能够自动使用共享库的正确版本(即最新版本)的独立于版本的链接命令了。
一般来讲,链接器名称与它所引用的文件位于同一个目录中,它既可以链接到真实名称,也可以连接到库的最新主要版本的soname。通常,最好使用指向soname的链接,因此对soname所做的变更会自动反应到链接器名称上。
如果需要将一个程序与共享库的一个较老的主要版本链接起来,就不能使用链接器名称。相反,在链接命令中需要通过制定具体的真实名称或soname来标示出所需要的版本(主要版本)。
下面是一些链接器名称的例子。
libdemo.so -> libdemo.so.2
libreadline.so -> libreadline.so.5
下图描绘了这些名称之间的关系。
使用标准规范创建一个共享库
根据上面介绍的相关知识,下面开始介绍如何遵循标准规范来构建一个演示库。首先需要创建目标文件。
$ gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
接着创建共享库,其真实名称为libdemo.so.1.0.1,soname为libdemo.so.1。
$ gcc -g -shared -Wl,-soname,libdemo.so.1 -o libdemo.so.1.0.1 mod1.o mod2.o mod3.o
接着为soname和链接器名称创建恰当的符号链接。
$ ln -s libdemo.so.1.0.1 libdemo.so.1
$ ln -s libdemo.so.1 libdemo.so
接着可以使用ls来验证配置(使用awk来选择感兴趣的字段)。
$ ls -l libdemo.so* | awk '{print $1,$9,$10,$11}'
lrwxrwxrwx libdemo.so ->libdemo.so.1
lrwxrwxrwx libdemo.so.1 ->libdemo.so.1.0.1
-rwxr-xr-x libdemo.so.1.0.1
接着可以使用链接器名称来构建可执行文件(注意链接命令不会用到版本号),并照常运行这个程序。
$ gcc -g -Wall -o prog prog.c -L. -ldemo
$ LD_LIBRARY_PATH=. ./prog
Called mod1-x1
Called mod2-x2
安装共享库
在本章到目前为止介绍的例子中都是将共享库创建在用户私有的目录中,然后使用LD_LIBRARY_PATH环境变量来确保动态链接器会搜到该目录。特权用户和非特权用户都可以使用这种技术,但在生产应用程序中不应该采用这种技术。一般来讲,共享库及其关联的符号链接会被安装在其中一个标准库目录中,标准库目录包括:
- /usr/lib,它是大多数标准库安装的目录。
- /lib,应该将系统启动时用到的库安装在这个目录中(因为在系统启动时可能还没有挂载/usr/lib)。
- /usr/local/lib,应该将非标准或实验性的库安装在这个目录中(对于/usr/lib是一个由多个系统共享的网络挂载但需要只在本机安装一个库的情况则可以将库放在这个目录中)。
- 其中一个在/etc/ld.so.conf(稍后介绍)中列出的目录。
在大多数情况下,将文件复制到这些目录中需要具备超级用户的权限。
安装完之后就必须要创建soname和链接器名称的符号链接了,通常它们是作为相对符号链接与库文件位于同一个目录中。因此要将本章的演示库安装在/usr/lib(只允许root进行更新)中则可以使用下面的命令。
$ su
Password:
# mv libdemo.so.1.0.1 /usr/lib
# cd /usr/lib
# ln -s libdemo.so.1.0.1 libdemo.so.1
# ln -s libdemo.so.1 libdemo.so
shell会话中的最后两行创建了soname和链接器名称的符号链接。
ldconfig
ldconfig(8)解决了共享库的两个潜在问题。
- 共享库可以位于各种目录中,如果动态链接器需要通过搜索所有这些目录来找出一个库并加载这个库,那么整个过程将非常慢。
- 当安装了新版本的库或者删除了旧版本的库,那么soname符号链接就不是最新的。
ldconfig程序通过执行两个任务来解决这些问题。
1. 它搜索一组标准的目录并创建或更新一个缓存文件/etc/ld.so.cache使之包含在所有这些目录中的主要库版本(每个库的主要版本的最新的次要版本)列表。动态链接器在运行时解析库名称时会轮流使用这个缓存文件。为了构建这个缓存,ldconfig会搜索在/etc/ld.so.conf中指定的目录,然后搜索/lib 和 /usr/lib。/etc/ld.so.conf文件由一个目录路径名(应该是绝对路径名)列表构成,其中路径名之间用换行、空格、制表符、逗号或冒号分隔。在一些发行版中,/usr/local/lib目录也位于这个列表中。(如果不在这个列表中,那么就需要手工将其添加到列表中。)
命令ldconfig –p
会显示/etc/ld.so.cache的当前内容。
2. 它检查每个库的各个主要版本的最新次要版本(即具有最大的次要版本号的版本)以找出嵌入的soname,然后在同一目录中为每个soname创建(或更新)相对符号链接。
为了能够正确执行这些动作,ldconfig要求库的名称要根据前面介绍的规范来命名(即库的真实名称包含主要和次要标识符,它们随着库的版本的更新而恰当的增长)。
在默认情况下,ldconfig会执行上面两个动作,但可以使用命令行选项来指定它执行其中一个动作:-N选项会防止缓存的重建,-X选项会阻止soname符号链接的创建。此外,-v (verbose)选项会使得ldconfig输出描述其所执行的动作的信息。
每当安装了一个新的库,更新或删除了一个既有库,以及/etc/ld.so.conf中的目录列表被修改之后,都应该运行ldconfig。
下面是一个使用ldconfig的例子。假设需要安装一个库的两个不同的主要版本,那么需要做下面的事情。
$ su
Password:
# mv libdemo.so.1.0.1 libdemo.so.2.0.0 /usr/lib
# ldconfig -v | grep libdemo
libdemo.so.1 -> libdemo.so.1.0.1(changed)
libdemo.so.2 -> libdemo.so.2.0.0(changed)
接着列出在/usr/lib目录中名为libdemo的文件来验证soname符号链接的设置。
# cd /usr/lib
# ls -l libdemo* | awk '{print $1,$$9,$10,$11}'
lrwxrwxrwx libdemo.so.1 -> libdemo.so.1.0.1
-rwxr-xr-x libdemo.so.1.0.1
lrwxrwxrwx libdemo.so.2 -> libdemo.so.2.0.0
-rwxr-xr-x libdemo.so.2.0.0
还需要为链接器名称创建符号链接,如下面的命令所示。
# ln -s libdemo.so.2 libdemo.so
如果安装了库的一个新的2.x次要版本,那么由于链接器名称指向了最新的soname,因此ldconfig还能取得保持链接器名称最新的效果,如下面的例子所示。
# mv libdemo.so.2.0.1 /usr/lib
# ldconfig -v | grep libdemo
libdemo.so.1 -> libdemo.so.1.0.1
libdemo.so.2 -> libdemo.so.2.0.1(changed)
如果创建和使用的是一个私有库(即没有安装在上述标准目录中的库),那么可以通过使用-n选项让ldconfig创建soname符号链接。这个选项指定了ldconfig只处理在命令行中列出的目录中的库,而无需更新缓存文件。下面的例子使用了ldconfig来处理当前工作目录中的库。
$ gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
$ gcc -g -shared -Wl,-soname,libdemo.so.1 -o libdemo.so.1.0.1 mod1.o mod2.o mod3.o
$ /sbin/ldconfig -nv .
.:
libdemo.so.1 -> libdemo.so.1.0.1
$ ls -l libdemo.so* | awk '{print $1,$9,$10,$11}'
lrwxrwxrwx libdemo.so.1 -> libdemo.so.1.0.1
-rwxr-xr-x libdemo.so.1.0.1
在上面的例子中,当运行ldconfig时指定了完全路径名,因为使用的是一个非特权账号,其PATH环境变量不包含/sbin目录。
兼容与不兼容库比较
随着时间的流逝,可能需要修改共享库的代码。这种修改会导致产生一个新版本的库,这个新版本可以与之前的版本兼容,也可能与之前的版本不兼容。如果是兼容的话则意味着只需要修改库的真实名称的次要版本标识符即可,如果是不兼容的话则意味着必须要定义一个库的新主要版本。
当满足下列条件时表示修改过的库与既有库版本兼容。
- 库中所有公共方法和变量的语义保持不变。换句话说,每个函数的参数列表不变并且对全局变量和返回参数产生的影响不变,同时返回同样的结果值。因此提升性能或修复Bug(导致更加行为更加符合规定)的变更可以认为是兼容的变更。
- 没有删除库的公共API中的函数和变量,但向公共API中添加新函数和变量不会影响兼容性。
- 在每个函数中分配的结构以及每个函数返回的结构保持不变。类似的,由库导出的公共结构保持不变。这个规则的一个例外情况是在特定情况下,可能会向既有结构的结尾处添加新的字段,但当调用程序在分配这个结构类型的数组时会产生问题。有时候,库的设计人员会通过将导出结构的大小定义为比库的首个发行版所需的大小大来解决这个问题,即增加一些填充字段以备将来之需。
如果所有这些条件都得到了满足,那么在更新新库名时就只需要调整既有名称中的次要版本号了,否则就需要创建库的一个新主要版本。
升级共享库
共享库的优点之一是当一个运行着的程序正在使用共享库的一个既有版本时也能够安装库的新主要版本或次要版本。在安装的过程中需要做的事情包括创建新的库版本、将其安装在恰当的目录中以及根据需要更新soname和链接器名称符号链接(或通常让ldconfig来完成这部分工作)。如要创建共享库/usr/lib/libdemo.1.0.1的一个新次要版本,那么需要完成:
$ su
Password
# gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
# gcc -g -shared -Wl,-soname,libdemo.so.1 -o libdemo.so.1.0.1 mod1.o mod2.o mod3.o
# mv libdemo.so.1.0.2 /usr/lib
# ldconfig -v | grep libdemo
libdemo.so.1 -> libdemo.so.1.0.2(changed)
假设已经正确地配置了链接器名称(即指向库的soname),那么就无需修改链接器名称了。
已经运行着的程序会继续使用共享库的上一个次要版本,只有当它们终止或重启之后才会使用共享库的新次要版本。
如果后面需要创建共享库的一个新主要版本(2.0.0),那么就需要完成:
$ su
Password
# gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
# gcc -g -shared -Wl,-soname,libdemo.so.1 -o libdemo.so.1.0.1 mod1.o mod2.o mod3.o
# mv libdemo.so.2.0.0 /usr/lib
# ldconfig -v | grep libdemo
libdemo.so.1 -> libdemo.so.1.0.2
libdemo.so.2 -> libdemo.so.2.0.1(changed)
# cd /usr/lib
# ln -sf libdemo.so.2 libdemo.so
从上面的输出可以看出,ldconfig自动为新主要版本创建了一个soname符号链接,但从最后一条命令可以看出,必须要手工更新链接器名称的符号链接。
在目标文件中指定库搜索目录
到目前为止本章已经介绍了两种通知动态链接器共享库的位置的方式:使用LD_LIBRARY_PATH环境变量和将共享库安装到其中一个标准库目录中(/lib、/usr/lib或在/etc/ld.so.conf中列出的其中一个目录)。
还存在第三种方式:在静态编辑阶段可以在可执行文件中插入一个在运行时搜索共享库的目录列表。这种方式对于库位于一个固定的但不属于动态链接器搜索的标准位置的位置中时是非常有用的。要实现这种方式需要在创建可执行文件时使用-rpath链接器选项。
$ gcc -g -Wall -Wl,-rpath,/home/mkt/pdir -o prog prog.c libdemo.so
上面的命令将字符串/home/mtk/pdir复制到了可执行文件prog的运行时库路径(rpath)列表中,因此当运行这个程序时,动态链接器在解析共享库引用时还会搜索这个目录。
如果有必要的话,可以多次指定–rpath选项;所有这些列出的目录会被连接成一个放到可执行文件中的有序rpath列表。或者,在一个rpath选项中可以指定多个由分号分割开来的目录列表。在运行时,动态链接器会按照在–rpath选项中指定的目录顺序来搜索目录。
-rpath选项的一个替代方案是LD_RUN_PATH环境变量。可以将一个由分号分隔开来的目录的字符串赋给该变量,当构建可执行文件时可以将这个变量作为rpath列表来使用。只有当构建可执行文件时不指定-rpath选项时才会使用LD_RUN_PATH变量。
在构建共享库时使用–rpath链接器选项
在构建共享库时–rpath选项也是有用的。假设有一个依赖于另一个共享库libx2.so的共享库libx1.so,如图41-4所示。另外再假设这些库分别位于非标准目录d1和d2中。下面介绍构建这些库以及使用它们的程序所需完成的步骤。
首先在pdir/d2目录中构建libx2.so。(为了使这个例子简单一点,这里省略了库的版本号和soname。)
$ cd /home/mtk/pdir/d2
$ gcc -g -c -fPUC -Wall modx2.c
$ gcc -g -shared -o libx2.so modx2.o
接着在pdir/d1目录中构建libx1.so。由于libx1.so依赖于libx2.so,并且libx2.so位于一个非标准目录中,因此在指定libx2.so的运行时位置时需要使用–rpath链接器选项。这个选项的取值与库的链接时位置(由-L选项指定)可以不同,尽管在这个例子中这两个位置是相同的。
$ cd /home/mtk/pdir/d1
$ gcc -g -c -Wall -fPIC modx1.c
$ gcc -g -shared -o libx1.so modx1.o -Wl,-rpath,/home/mtk/pdir/d2 -L/home/mtk/pdir/d2 -lx2
最后在pdir目录中构建主程序。由于主程序使用了libx1.so并且这个库位于一个非标准目录中,因此还需要使用–rpath链接器选项。
$ cd /home/mtk/pdir
$ gcc -g -Wall -o prog prog.c -Wl,-rpath,/home/mtk/pdir/d1/ -L/home/mtk/pdir/d1 -lx1
注意在链接主程序时无需指定libx2.so。由于链接器能够分析libx1.so中的rpath列表,因此它能够找到libx2.so,同时在静态链接阶段解析出所有的符号。
使用下面的命令能够检查prog和libx1.so以便查看它们的rpath列表的内容。
$ objdump -p prog | grep PATH
RPATH /home/mtk/pdir/d1
$ objdump -p d1/libx1.so | grep PATH
RPATH /home/mtk/pdir/d2
还可以通过查找readelf – –dynamic(或等价的readelf –d)命令的输出来查看rpath列表。
使用ldd命令能够列出prog的完整的动态依赖列表。
$ ldd prog
ELF DT_RPATH和DT_RUNPATH条目
在第一版ELF规范中,只有一种rpath列表能够被嵌入到可执行文件或共享库中,它对应于ELF文件中的DT_RPATH标签。后续的ELF规范舍弃了DT_RPATH,同时引入了一种新标签DT_RUNPATH来表示rpath列表。这两种rpath列表之间的差别在于当动态链接器在运行时搜索共享库时它们相对于LD_LIBRARY_PATH环境变量的优先级:DT_RPATH的优先级更高,而DT_RUNPATH的优先级则更低。
在默认情况下,链接器会将rpath列表创建为DT_RPATH标签。为了让链接器将rpath列表创建为DT_RUNPATH条目必须要额外使用– –enable–new–dtags(启用新动态标签)链接器选项。如果使用这个选项重建程序并且使用objdump查看获得的可执行文件,那么将会看到下面这样的输出。
$ objdump -p prog | grep PATH
RPATH /home/mtk/pdir/d1
RUNPATH /home/mtk/pdir/d1
从上面可以看出,可执行文件包含了DT_RPATH和DT_RUNPATH标签。链接器采用这种方式复写了rpath列表是为了让不理解DT_RUNPATH标签的老式动态链接器能够正常工作。(glibc 2.2增加了对DT_RUNPATH的支持)。理解DT_RUNPATH标签的链接器会忽略DT_RPATH标签。
在rpath中使用$ORIGIN
假设需要发布一个应用程序,这个应用程序使用了自身的共享库,但同时不希望强制要求用户将这些库安装在其中一个标准目录中,相反,需要允许用户将应用程序解压到任意异目录中,然后能够立即运行这个应用程序。这里存在的问题是应用程序无法确定存放共享库的位置,除非要求用户设置LD_LIBRARY_PATH或者要求用户运行某种能够标识出所需的目录的安装脚本,但这两种方法都不是令人满意的方法。
为解决这个问题,在构建链接器的时候增加了对rpath规范中特殊字符串$ORIGIN(或等价的${ORIGIN})的支持。动态链接器将这个字符串解释成“包含应用程序的目录”。这意味着可以使用下面的命令来构建应用程序。
$ gcc -Wl,-rpath,'$ORIGIN'/lib ...
上面的命令假设在运行时应用程序的共享库位于包含应用程序的可执行文件的目录的子目录lib中。这样就能向用户提供一个简单的包含应用程序及相关的库的安装包,同时允许用户将这个包安装在任意位置并运行这个应用程序了(即所谓的“turn-key应用程序”)。
在运行时找出共享库
在解析库依赖时,动态链接器首先会检查各个依赖字符串以确定它是否包含斜线(/),因为在链接可执行文件时如果指定了一个显式的库路径名的话就会发生这种情况。如果找到了一个斜线,那么依赖字符串就会被解释成一个路径名(绝对路径名或相对路径名),并且会使用该路径名加载库。否则动态链接器会使用下面的规则来搜索共享库。
1. 如果可执行文件的DT_RPATH运行时库路径列表(rpath)中包含目录并且不包含DT_RUNPATH列表,那么就搜索这些目录(按照链接程序时指定的目录顺序)。
2. 如果定义了LD_LIBRARY_PATH环境变量,那么就会轮流搜索该变量值中以冒号分隔的各个目录。如果可执行文件是一个set-user-ID或set-group-ID程序,那么就会忽略LD_LIBRARY_PATH变量。这项安全措施是为了防止用户欺骗动态链接器让其加载一个与可执行文件所需的库的名称一样的私有库。
3. 如果可执行文件DT_RUNPATH运行时库路径列表中包含目录,那么就会搜索这些目录(按照链接程序时指定的目录顺序)。
4. 检查/etc/ld.so.cache文件以确认它是否包含了与库相关的条目。
5. 搜索/lib和/usr/lib目录(按照这个顺序)。
运行时符号解析
假设在多个地方定义了一个全局符号(即函数或变量),如在一个可执行文件和一个共享库中或在多个共享库中。那么如何解析指向这个符号的引用呢?
假设现在有一个主程序和一个共享库,它们两个都定义了一个全局函数xyz(),并且共享库中的另一个函数调用了xyz(),如图所示。
在构建共享库和可执行程序并运行这个程序之后能够看到下面的输出。
$ gcc -g -c -fPIC -Wall -c foo.c
$ gcc -g -shared -o libfoo.so foo.o
$ gcc -g -o prog prog.c libfoo.so
$ LD_LIBRARY_PATH=. ./prog
main-xyz
从上面输出的最后一行可以看出,主程序中的xyz()定义覆盖(优先)了共享库中的定义。
尽管这种处理方式在一开始看起来有些令人惊讶,但这样做是有历史原因的。第一个共享库实现在设计时的目标是使符号解析的默认语义与那些和同一库等价的静态库进行链接的应用程序中的符号解析的语义完成一致。这意味着下面的语义是正确的。
- 主程序中全局符号的定义覆盖库中相应的定义。
- 如果一个全局符号在多个库中进行了定义,那么对该符号的引用会被绑定到在扫描库时找到的第一个定义,其中扫描顺序是按照这些库在静态链接命令行中列出时从左至右的顺序。
虽然这些语义使得从静态库到共享库的转变变得相对简单了,但这种做法会导致一些问题。其中最大的问题是这些语义在使用共享库实现一个自包含的子系统时会与共享库模型产生矛盾。在默认情况下,共享库无法确保一个指向其自身的某个全局符号的引用会真正被绑定到该符号在库中的定义上,从而导致当该共享库被集成到一个更大的系统中时共享库的属性可能会发生改变。这会导致应用程序出现令人意料之外的行为,同时也使得分治调试的执行变得更加困难(即尝试使用更少或不同的共享库来重现问题)。
在上面的例子中,如果想要确保在共享库中对xyz()的调用确实调用了库中定义的相应函数,那么在构建共享库的时候就需要使用–Bsymbolic链接器选项。
$ gcc -g -c -fPIC -Wall -c foo.c
$ gcc -g -shared -Wl,-Bsymbolic -o libfoo.so foo.o
$ gcc -g -o prog prog.c libfoo.so
$ LD_LIBRARY_PATH=. ./prog
foo-xyz
–Bsymbolic链接器选项指定了共享库中对全局符号的引用应该优先被绑定到库中的相应定义上(如果存在的话)。(注意不管是否使用了这个选项,在主程序中调用xyz()总是会调用主程序中定义的xyz()。)
使用静态库取代共享库
虽然在大多数情况下都应该使用共享库,但在某些场景中静态库则更加适合。特别地,静态链接的应用程序包含了它在运行时所需的全局代码这一事实是非常有利的。如当用户不希望或者无法在运行程序的系统上安装共享库或者程序在另一个无法使用共享库的环境中运行时(如可能是一个chroot监狱(jail)),静态链接就派上用场了。此外,即使是一个兼容的共享库升级也可能会在无意中引入一个Bug,从而导致应用程序无法正常工作。通过静态链接应用程序就能确保系统上共享库的变动不会影响到它并且它已经拥有了运行所需的全局代码(付出的代价就是程序更大了,从而会需要更多的磁盘空间和内存)。
在默认情况下,当链接器能够选择名称一样的共享库和静态库时(如在链接时使用–Lsomedir –ldemo并且libdemo.so和libdemo.a都存在)会优先使用共享库。要强制使用库的静态版本则可以完成下列之一。
- 在gcc命令行中指定静态库的路径名(包括.a扩展)。
- 在gcc命令行中指定-static选项。
- 使用–Wl,–Bstatic和–Wl,–Bdynamic gcc选项来显式地指定链接器选择共享库还是静态库。在gcc命令行中可以使用-l选项来混合这些选项。链接器会按照选项被指定时的顺序来处理这些选项。
共享库高级特性
动态加载库
当一个可执行文件开始运行之后,动态链接器会加载程序的动态依赖列表中的所有共享库,但有些时候延迟加载库是比较有用的,如只在需要的时候再加载一个插件。动态链接器的这项功能是通过一组API来实现的。这组API通常被称为dlopen API,它源自Solaris,现在其中大部分内容都在SUSv3中进行了规定。
dlopen API使得程序能够在运行时打开一个共享库,根据名字在库中搜索一个函数,然后调用这个函数。在运行时采用这种方式加载的共享库通常被称为动态加载的库,它的创建方式与其他共享库的创建方式完全一样。
核心dlopen API由下列函数(所有这些函数都在SUSv3进行了规定)构成。
- dlopen()函数打开一个共享库,返回一个供后续调用使用的句柄。
- dlsym()函数在库中搜索一个符号(一个包含函数或变量的字符串)并返回其地址。
- dlclose()函数关闭之前由dlopen()打开的库。
- dlerror()函数返回一个错误消息字符串,在调用上述函数中的某个函数发生错误时可以使用这个函数来获取错误消息。
glibc实现还包含了一组相关的函数,其中一些将会在后面予以介绍。
要在Linux上使用dlopen API构建程序必须要指定–ldl选项以便与libdl库链接起来。
打开共享库:dlopen()
dlopen()函数将名为libfilename的共享库加载进调用进程的虚拟地址空间并增加该库的打开引用计数。
#include<dlfcn.h>
void *dlopen(const char *libfilename,int flags);
如果libfilename包含了一个斜线(/),那么dlopen()会将其解释成一个绝对或相对路径名,否则动态链接器会使用第41.11节中介绍的规则来搜索共享库。
dlopen()在成功时会返回一个句柄,在后续对dlopen API中的函数的调用可以使用该句柄来引用这个库。如果发生了错误(如无法找到库),那么dlopen()会返回NULL。
如果libfilename指定的共享库依赖于其他共享库,那么dlopen()会自动加载那些库。如果有必要的话,这一过程会递归进行。这种被加载进来的库被称为这个库的依赖树。
在同一个库文件中可以多次调用dlopen(),但将库加载进内存的操作只会发生一次(第一次调用),所有的调用都返回同样的句柄值。但dlopen API会为每个库句柄维护一个引用计数,每次调用dlopen()时都会增加引用计数,每次调用dlclose()都会减小引用计数,只有当计数为0时dlclose()才会从内存中删除这个库。
flags参数是一个位掩码,它的取值是RTLD_LAZY和RTLD_NOW中的一个,这两个值的含义分别如下。
RTLD_LAZY
只有当代码被执行的时候才解析库中未定义的函数符号。如果需要某个特定符号的代码没有被执行到,那么永远都不会解析该符号。延迟解析只适用于函数引用,对变量的引用会被立即解析。指定RTLD_LAZY标记能够提供与在加载可执行文件的动态依赖列表中的共享库时动态链接器的常规操作对应的行为。
RTLD_NOW
在dlopen()结束之前立即加载库中所有的未定义符号,不管是否需要用到这些符号,这种做法的结果是打开库变得更慢了,但能够立即检测到任何潜在的未定义函数符号错误,而不是在后面某个时刻才检测到这种错误。在调试应用程序时这种做法是比较有用的,因为它能够确保应用程序在碰到未解析的符号时立即发生错误,而不是在执行了很长一段时间之后才发生错误。
通过将环境变量LD_BIND_NOW设置为一个非空字符串能够强制动态链接器在加载可执行文件的动态依赖列表中的共享库时立即解析所有符号(即类似于RTLD_NOW)。这个环境变量在glibc 2.1.1以及后续的版本中是有效的。设置LD_BIND_NOW会覆盖dlopen() RTLD_LAZY标记的效果。
flags也可以取其他的值,SUSv3规定了下列几种标记。
RTLD_GLOBAL
这个库及其依赖树中的符号在解析由这个进程加载的其他库中的引用和通过dlsym()查找时可用。
RTLD_LOCAL
与RTLD_GLOBAL相反,如果不指定任何常量,那么就取这个默认值。它规定在解析后续加载的库中的引用时这个库及其依赖树中的符号不可用。
在不指定RTLD_GLOBAL或RTLD_LOCAL时,SUSv3并没有规定一个默认值。大多数UNIX实现与Linux一样,将RTLD_LOCAL作为默认值,但一些实现将RTLD_GLOBAL作为默认值。
Linux还支持几个并没有在SUSv3中进行规定的标记,如下所示。
RTLD_NODELETE(自glibc 2.2起)
在dlclose()调用中不要卸载库,即使其引用计数已经变成0了。这意味着在后面重新通过dlopen()加载库时不会重新初始化库中的静态变量。(对于由动态链接器自动加载的库来讲,在创建库时通过指定gcc –Wl,–znodelete选项能够取得类似的效果。)
RTLD_NOLOAD(自glibc 2.2起)
不加载库。这个标记有两个目的。第一,可以使用这个标记来检查某个特定的库是否已经被加载到了进程的地址空间中。如果已经加载了,那么dlopen()会返回库的句柄,如果没有加载,那么dlopen()会返回NULL。第二,可以使用这个标记来“提升”已加载的库的标记。如在对之前使用RTLD_LOCAL打开的库调用dlopen()时可以在flags参数中指定RTLD_NOLOAD | RTLD_GLOBAL。
RTLD_DEEPBIND(自glibc 2.3.4)
在解析这个库中的符号引用时先搜索库中的定义,然后再搜索已加载的库中的定义。这个标记使得一个库能够实现自包含,即优先使用自己的符号定义,而不是在已加载的其他库中定义的同名全局符号。(这与–Bsymbolic链接器选项具有类似的效果。)
RTLD_NODELETE和RTLD_NOLOAD标记在Solaris dlopen API中也进行了实现,但提供这个两个标记的UNIX实现很少。RTLD_DEEPBIND标记是Linux特有的。
当将libfilename指定为NULL时dlopen()会返回主程序的句柄。(SUSv3将这种句柄称为“全局符号对象”的句柄。)在后续对dlsym()的调用中使用这个句柄会导致首先在主程序中搜索符号,然后在程序启动时加载的共享库中进行搜索,最后在所有使用了RTLD_GLOBAL标记的动态加载的库中进行搜索。
错误诊断:dlerror()
如果在dlopen()调用或dlopen API的其他函数调用中得到了一个错误,那么可以使用dlerror()来获取一个指向表明错误原因的字符串的指针。
#include<dlfcn.h>
const char *dlerror(void);
如果从上一次调用dlerror()到现在没有发生错误,那么dlerror()函数返回NULL,读者在下一节中就会看到这种处理方式带来的好处了。
获取符号的地址:dlsym()
dlsym()函数在handle指向的库以及该库的依赖树中的库中搜索名为symbol的符号(函数或变量)。
#include<dlfcn.h>
void *dlsym(void *handle,char *symbol);
如果找到了symbol,那么dlsym()会返回其地址,否则就返回NULL。handle参数通常是上一个dlopen()调用返回的库句柄,或者它也可以是下面介绍的其中一个所谓的伪句柄。
dlvsym(handle, symbol, version)与dlsym()类似,但它能够用来在符号版本化的库中搜索版本与在字符串version中指定的版本匹配的符号定义。要从<dlfcn.h>中获取这个函数的声明必须要定义_GNU_SOURCE特性测试宏。
dlsym()返回的符号值可能会是NULL,这一点与“找不到符号”的返回是无法区分的。为了弄清楚具体是哪种情况就必须要先调用dlerror()(确保之前的错误字符串已经被清除了),如果在调用dlsym()之后dlerror()返回了一个非NULL值,那么就可以得出发生错误的结论了。
如果symbol是一个变量的名称,那么可以将dlsym()的返回值赋给一个合适的指针类型,并通过反引用该指针来得到变量的值。
int *ip;
ip=(int *)dlsym(symbol,"myvar");
if(ip != NULL)
printf("Value is %d\n", *ip);
{%g%}
指针和空指针
指针包含首地址和大小两部分,空指针(void *)只包含地址不包含大小,int 型指针(int *)包含首地址和4个字节大小的内存。
所以空指针可以转化为其他指针的意思就是,给空指针指定了内存大小。
{%endg%}
如果symbol是一个函数的名称,那么可以使用dlsym()返回的指针来调用该函数。可以将dlsym()返回的值存储到一个类型合适的指针中,如下所示。
int (*funcp)(int);
但不能简单地将dlsym()的结果赋给此类指针,如下面的例子所示。
funcp=dlsym(handle,symbol);
其原因是C99标准禁止函数指针和void *之间的赋值操作。这个问题的解决方案是使用下面这样的(稍微有些笨拙)类型转换。
*(void **)(&funcp) = dlsym(handle,symbol);
通过dlsym()得到了指向函数的指针之后就能够通过常规的C语法反引用函数指针来调用这个函数了。
res = (*funcp)(somearg);
读者在将dlsym()的返回值进行赋值时可能会使用下面这段看起来与上述代码等价的代码来取代上面的*(void **)语法。
(void *)funcp = dlsym(handle,symbol);
但gcc –pedantic在碰到上面这段代码时会发出“ANSI C forbids the use of cast expressions as lvalues.”的警告信息。而使用*(void **)语言就不会出现这个警告信息,因为是在向赋值语句中的左值指向的地址赋值。
在很多UNIX实现中可以使用下面这样的类型转换类消除C编译器的警告。
funcp = (int (*) (int)) dlsym(handle,symbol);
但SUSv3 Technical Corrigendum Number 1中dlsym()的规范指出C99标准仍然要求编译器对此类转换生成警告信息并列举了上面的*(void **)语法。
SUSv3 TC1指出由于需要用到*(void **)语法,因此标准的后续版本可能会定义一个与dlsym()类似的API来处理数据和函数指针。但SUSv4在这一点上没有发生任何变化。
在dlsym()中使用库伪句柄
dlsym()函数中的handle参数除了能够取由dlopen()调用返回的句柄值之外,还能够取下列伪句柄值。
RTLD_DEFAULT
从主程序中开始查找symbol,接着按序在所有已加载的共享库中查找,包括那些通过使用了RTLD_GLOBAL标记的dlopen()调用动态加载的库,这个标记对应于动态链接器所采用的默认搜索模型。
RTLD_NEXT
在调用dlsym()之后加载的共享库中搜索symbol,这个标记适用于需要创建与在其他地方定义的函数同名的包装函数的情况。如,在主程序中可能会定义一个malloc()(它可能完成内存分配的簿记工作),而这个函数在调用实际的malloc()之前首先会通过调用func = dlsym(RTLD_NEXT, “malloc”)来获取其地址。
SUSv3并没有要求实现上述列出的伪句柄(甚至没有保留这两个值以供后续之用),并且所有UNIX实现也没有定义上述伪句柄。为了从<dlfcn.h>中获取这些常量的定义必须要定义_GNU_SOURCE特性测试宏。
关闭共享库:dlclose()
dlclose()函数关闭一个库。
#include<dlfcn.h>
int dlclose(void *handle);
dlclose()函数会减小handle所引用的库的打开引用的系统计数。如果这个引用计数变成了0并且其他库已经不需要用到该库中的符号了,那么就会卸载这个库。系统也会在这个库的依赖树中的库执行(递归地)同样的过程。当进程终止时会隐式地对所有库执行dlclose()。
从glibc 2.2.3开始,共享库中的函数可以使用atexit()(或on_exit())来设置一个在库被卸载时自动调用的函数。
获取与加载的符号相关的信息:dladdr()
dladdr()返回一个包含地址addr(通常通过前面的dlsym()调用获得)的相关信息的结构。
#define _GNU_SOURCE
#include <dlfcn.h>
int dladdr(const void *addr, Dl_info *info);
info参数是一个指向由调用者分配的结构的指针,其结构形式如下。
typedef struct {
const char *dli_fname; //包含‘addr’共享库的路径名
void *dli_fbase; //被加载共享库的基地址
const char *dli_sname;
void *dli_saddr;
}Dl_info;
Dl_info结构中的前两个字段指定了包含地址addr的共享库的路径名和运行时基地址。最后两个字段返回地址相关的信息。假设addr指向共享库中一个符号的确切地址,那么dli_saddr返回的值与传入的addr值一样。
SUSv3并没有规定dladdr(),所有UNIX实现也都没有提供这个函数。
在主程序中访问符号
假设使用dlopen()动态加载了一个共享库,然后使用dlsym()获取了共享库中x()函数的地址,接着调用x()。如果在x()中调用了函数y(),那么通常会在程序加载的其中一个共享库中搜索y()。
有些时候需要让x()调用主程序中的y()实现(类似于回调机制)。为了达到这个目的就必须要使主程序中的符号(全局作用域)对动态链接器可用,即在链接程序时使用− −export−dynamic链接器选项。
$ gcc -Wl,--export-dynamic main.c
或者可以编写下面这个等价的命令。
$ gcc -export-dynamic main.c
使用这些选项中的一个就能够允许动态加载的库访问主程序中的全局符号。
gcc –rdynamic选项和gcc –Wl、–E选项的含义,以及–Wl、− −export–dynamic是一样的。
控制符号的可见性
设计良好的共享库应该只公开那些构成其声明的应用程序二进制接口(ABI)的符号(函数和变量),其原因如下。
- 如果共享库的设计人员不小心导出了未详细说明的接口,那么使用这个库的应用程序的作者可能会选择使用这些接口。这样在将来升级共享库时可能会带来兼容性问题。库的开发人员认为可以修改或删除那些不属于文档中记录的ABI中的接口,而库的用户则希望继续使用名称与他们当前正在使用的接口名称一样的接口(同时语义保持不变)。
- 在运行时符号解析阶段,由共享库导出的所有符号可能会优先于其他共享库提供的相关定义(参见41.12节)。
- 导出非必需的符号会增加在运行时需加载的动态符号表的大小。
当库的设计人员确保只导出那些库的声明的ABI所需的符号就能使上述问题发生的可能性降到最低或避免上述问题的发生。下列技术可以用来控制符号的导出。
- 在C程序中可以使用static关键词使得一个符号私有于一个源代码模块,从而使得它无法被其他目标文件绑定。
除了使一个符号私有于源代码模块之外,static关键词还能达到一个相反的效果。如果一个符号被标记为static,那么在同一源文件中对该符号的所有引用会被绑定到该符号的定义上,其结果是这些引用在运行时不会被关联到其他共享库中的相应定义上。static关键词的这种效果类似于链接器选项,但差别在于static关键词只影响单个源文件中的单个符号。 - GNU C编译器gcc提供了一个特有的特性声明,它执行与static关键词类似的任务。
void
__attribute__((visibility("hidden")))
func(void){
//Code
}
static关键词将一个符号的可见性限制在单个源代码文件中,而hidden特性使得一个符号对构成共享库的所有源代码文件都可见,但对库之外的文件不可见。
与static关键词一样,hidden特性也能达到一个相反的效果,即防止在运行时发生符号插入。
3. 版本脚本可以用来精确控制符号的可见性以及选择将一个引用绑定到符号的哪个版本。
4. 当动态加载一个共享库时,dlopen()接收的RTLD_GLOBAL标记可以用来指定这个库中定义的符号应该用于后续加载的库中的绑定操作,– –export–dynamic链接器选项可以用来使主程序的全局符号对动态加载的库可用。
链接器版本脚本
版本脚本是一个包含链接器ld执行的指令的文本文件。要使用版本脚本必须要指定– –version–script链接器选项。
$ gcc -Wl,--version-script,myscriptfile.map ...
版本脚本的后缀通常(但不统一)是.map。
使用版本脚本控制符号的可见性
版本脚本的一个用途是控制那些可能会在无意中变成全局可见(即对与该库进行链接的应用程序可见)的符号的可见性。举一个简单的例子,假设需要从三个源文件vis_comm.c、vis_f1.c以及 vis_f2.c中构建一个共享库,这三个源文件分别定义了函数vis_comm()、vis_f1()以及 vis_f2()。vis_comm()函数由vis_f1() 和 vis_f2()调用,但不想被与该库进行链接的应用程序直接使用。再假设使用常规的方式来构建共享库。
$ gcc -g -c -fPIC -Wall vis_comm.c vis_f1.c vis_f2.c
$ gcc -g -shared -o vis.so vis_comm.o vis_f1.o vis_f2.o
如果使用下面的readelf命令来列出该库导出动态符号,那么就会看到下面的输出。
$ readelf --syms --use-dynamic vis.so | grep vis_
30 12: 00000790 59 FUNC GLOBAL DEFAULT 10 vis_f1
25 13: 000007d0 73 FUNC GLOBAL DEFAULT 10 vis_f2
27 16: 00000770 20 FUNC GLOBAL DEFAULT 10 vis_comm
这个共享库导出了三个符号:vis_comm()、vis_f1()以及vis_f2(),但这里需要确保这个库只导出vis_f1()和vis_f2()符号。这种效果可以通过下面的版本脚本来实现。
$ cat vis.map
VER_1{
global:
vis_f1;
vis_f2;
local:
*;
};
标识符VER_1是一种版本标签。在42.3.2节对符号版本化的讨论中将会看到一个版本脚本可以包含多个版本节点,每个版本节点以括号({})组织起来并且在括号前面设置一个唯一的版本标签。如果使用版本脚本只是为了控制符号的可见性,那么版本标签是多余的,但老版本的ld仍然需要用到这个标签。ld的现代版本允许省略版本标签,如果省略了版本标签的话就认为版本节点拥有一个匿名版本标签并且在这个脚本中不能存在其他版本节点。
在版本节点中,关键词global标记出了以分号分隔的对库之外的程序可见的符号列表的起始位置,关键词local标记出了以分号分隔的对库之外的程序隐藏的符号列表的起始位置。上面的星号()说明在符号规范中可以使用掩码模式,所使用的掩码字符与shell文件名匹配中使用的掩码字符是一样的——如和?。(更多细节请参考glob(7)手册。)在本例中,local规范中的星号表示除了在global段中显式声明的符号之外的所有符号都对外隐藏。如果不这样声明,那么vis_comm()仍然是可见的,因为在默认情况下C全局符号对共享库之外的程序是可见的。
接着可以像下面这样使用版本脚本来构建共享库。
$ gcc -g -c -fPIC -Wall vis_comm.c vis_f1.c vis_f2.c
$ gcc -g -shared -o vis.so vis_comm.o vis_f1.o vis_f2.o -Wl,--version-script,vis.map
再次使用readelf可以看出vis_comm()不再对外可见了。
$ readelf --syns --use-dynamic vis.so | grep vis_
25 0: 00000730 73 FUNC GLOBAL DEFAULT 11 vis_f2
29 16: 000006f0 59 FUNC GLOBAL DEFAULT 11 vis_f1
符号版本化
符号版本化允许一个共享库提供同一个函数的多个版本。每个程序会使用它与共享库进行(静态)链接时函数的当前版本。这种处理方式的结果是可以对共享库进行不兼容的改动而无需提升库的主要版本号。从极端的角度来讲,符号版本化可以取代传统的共享库主要和次要版本化模型。glibc从2.1开始使用了这种符号版本化技术,因此glibc 2.0以及之前的所有版本都是通过单个主要库版本(libc.so.6)来支持的。
下面通过一个简单的例子来展示符号版本化的用途。首先使用一个版本脚本来创建共享库的第一个版本。
$ cat sv_lib_v1.c
#include<stdio.h>
void xyz(void){printf("v1 xyz\n");}
$ cat sv_v1.map
VER_1{
global: xyz;
local: *;
};
$ gcc -g -c -fPIC -Wall sv_lib_v1.c
$ gcc -g -shared -o libsv.so sv_lib_v1.o -Wl,--version-script,sv_v1.map
在这个阶段,版本脚本sv_v1.map只用来控制共享库的符号的可见性,即只导出xyz(),同时隐藏其他所有符号(在这个简短的例子中没有其他符号了)。接着创建一个程序pl来使用这个库。
$ cat sv_prog.c
#include<stdio.h>
int main()
{
void xyz(void);
xyz();
return 0;
}
$ gcc -g -o p1 sv_prog.c libsv.so
运行这个程序之后就能看到预期的结果。
$ LD_LIBRARY_PATH=. ./p1
v1 xyz
现在假设需要修改库中xyz()的定义,但同时仍然需要确保程序pl继续使用老版本的函数。为完成这个任务,必须要在库中定义两个版本的xyz()。
$ cat sv_lib_v2.c
#include<stdio.h>
__asm__(".symver xyz_old,xyz@VER_1");
__asm__(".symver xyz_new,xyz@@VER_2");
void xyz_old(void){printf("v1 xyz\n");}
void xyz_new(void){printf("v2 xyz\n");}
void pqr(void){printf("v2 pqr\n");}
这里两个版本的xyz()是通过函数xyz_old()和xyz_new()来实现的。xyz_old()函数对应于原来的xyz()定义,pl程序应该继续使用这个函数。xyz_new()函数提供了与库的新版本进行链接的程序所使用的xyz()的定义。
修改过的版本脚本(稍后给出)中的两个.symver汇编器指令将这两个函数绑定到了两个不同的版本标签上,下面将使用这个脚本来创建共享库的新版本。第一个指令指示与版本标签VER_1进行链接的应用程序(即程序pl)所使用的xyz()的实现是xyz_old(),与版本标签VER_2进行链接的应用程序所使用的xyz()的实现是xyz_new()。
第二个.symver指令使用@@(不是@)来指示当应用程序与这个共享库进行静态链接时应该使用的xyz()的默认定义。一个符号的.symver指令中应该只有一个指令使用@@标记。
下面是与修改过之后的库对应的版本脚本。
$ cat sv_v2.map
VER_1{
global: xyz;
local: *;
};
VER_2{
global: pqr;
}VER_1;
这个版本脚本提供了一个新版本标签VER_2,它依赖于标签VER_1。这种依赖关系是通过下面这行进行标记的。
}VER_1;
版本标记依赖表明了相邻两个库版本之间的关系。从语义上来讲,Linux上的版本标签依赖的唯一效果是版本节点可以从它所依赖的版本节点中继承global和local规范。
依赖可以串联起来,这样就可以定义另一个依赖于VER_2的版本节点VER_3并以此类推地定义其他版本节点。
版本标签名本身是没有任何意义的,它们相互之间的关系是通过制定的版本依赖来确定的,因此这里选择名称VER_1和VER_2仅仅为了暗示它们之间的关系。为了便于维护,建议在版本标签名中包含包名和一个版本号。如glibc会使用名为GLIBC_2.0和GLIBC_2.1之类的版本标签名。
VER_2版本标签还指定了将库中的pqr()函数导出并绑定到VER_2版本标签。如果没有通过这种方式来声明pqr(),那么VER_2版本标签从VER_1版本标签继承而来的local规范将会使pqr()对外不可见。还需注意的是如果省略了local规范,那么库中的xyz_old()和xyz_new()符号也会被导出(这通常不是期望发生的事情)。
现在按照以往方式构建库的新版本。
$ gcc -g -c -fPIC -Wall sv_lib_v2.c
$ gcc -g -shared -o libsv.so sv_lib_v1.o -Wl,--version-script,sv_v2.map
现在创建一个新程序p2,它使用了xyz()的新定义,同时程序p1使用了旧版的xyz()。
$ gcc -g -o p2 sv_prog.c libsv.so
$ LD_LIBRARY_PATH=. ./p2
v2 xyz
$ LD_LIBRARY_PATH=. ./p1
v1 xyz
可执行文件的版本标签依赖是在静态链接时进行记录的。使用objdump –t可以打印出每个可执行文件的符号表,从而能够显示出两个程序中不同的版本标签依赖。
$ objdump -t p1 | grep xyz
08048380 F *UND* 0000002e xyz@@VER_1
$ objdump -t p2 | grep xyz
080483a0 F *UND* 0000002e xyz@@VER_1
还可以使用readelf –s获取类似的信息。
初始化和终止函数
可以定义一个或多个在共享库被加载和卸载时自动执行的函数,这样在使用共享库时就能够完成一些初始化和终止工作了。不管库是自动被加载还是使用dlopen接口显式加载的,初始化函数和终止函数都会被执行。
初始化和终止函数是使用gcc的constructor和destructor特性来定义的。在库被加载时需要执行的所有函数都应该定义成下面的形式。
void __attribute__ ((constructor)) some_name_load(void)
{
//初始化代码
}
类似地,卸载函数的形式如下。
void __attribute__ ((destructor)) some_name_unload(void)
{
//终止代码
}
读者可以根据需要使用其他名字替换函数名some_name_load()和some_name_unload()。
使用gcc的constructor和destructor特性还能创建主程序的初始化函数和终止函数。
_init()和_fini()函数
用来完成共享库的初始化和终止工作的一项较早的技术是在库中创建两个函数_init()和_fini()。当库首次被进程加载时会执行void _init(void)中的代码,当库被卸载时会执行void _fini(void)函数中的代码。
如果创建了_init()和_fini()函数,那么在构建共享库时必须要指定gcc –nostartfiles选项以防止链接器加入这些函数的默认实现。(如果需要的话可以使用–Wl,–init和–Wl,–fini链接器选项来指定函数的名称。)
有了gcc的constructor和destructor特性之后已经不建议使用_init()和_fini()函数了,因为gcc的constructor和destructor特性允许定义多个初始化和终止函数。
预加载共享库
出于测试的目的,有些时候可以有选择地覆盖一些正常情况下会被动态链接器按照41.11节中介绍的规则找出的函数(以及其他符号)。要完成这个任务可以定义一个环境变量LD_PRELOAD,其值由在加载其他共享库之前需加载的共享库名称构成,其中共享库名称之间用空格或冒号分隔。由于首先会加载这些共享库,因此可执行文件自动会使用这些库中定义的函数,从而覆盖那些动态链接器在其他情况下会搜索的同名函数。如假设有一个程序调用了函数x1()和x2(),并且这两个函数在libdemo库中进行了定义。这样当运行这个程序时会看到下面这样的输出。
$ ./prog
Called mod1-x1 DEMO
Called mod2-x2 DEMO
(在本例中假设共享库位于其中一个标准目录中,因此无需使用LD_LIBRARY_PATH环境变量。)
接着需要覆盖函数x1(),这可以通过创建另一个包含了不同的x1()定义的共享库libalt.so来完成。在运行这个程序时预加载这个库会得到下面的输出。
$ LD_PRELOAD=libalt.xo ./prog
Called mod1-x1 ALT
Called mod2-x2 DEMO
从上面的输出可以看出程序调用了libalt.so中定义的x1(),但libalt.so并没有定义x2(),因此对x2()的调用仍然会调用libdemo.so中定义的x2()函数。
LD_PRELOAD环境变量控制着进程级别的预加载行为。或者可以使用/etc/ld.so.preload文件来在系统层面完成同样的任务,该文件列出了以空格分隔的库列表。(LD_PRELOAD指定的库将在加载/etc/ld.so.preload指定的库之前加载。)
出于安全原因,set-user-ID和set-group-ID程序忽略了LD_PRELOAD。
监控动态链接器:LD_DEBUG
有些时候需要监控动态链接器的操作以弄清楚它在搜索哪些库,这可以通过LD_DEBUG环境变量来完成。通过将这个变量设置为一个(或多个)标准关键词可以从动态链接器中得到各种跟踪信息。
如果将help赋给LD_DEBUG,那么动态链接器会输出有关LD_DEBUG的帮助信息,而指定的命令不会被执行。
$ LD_DEBUG=help date
Valid options for the LD_DEBUG environment variable are:
libs display library search paths
reloc display relocation processing
files display progress for input file
symbols display symbol table processing
bindings display information about symbol binding
versions display version dependencies
scopes display scope information
all all previous options combined
statistics display relocation statistics
unused determined unused DSOs
help display this help message and exit
To direct the debugging output into a file instead of standard output
a filename can be specified using the LD_DEBUG_OUTPUT environment variable.
要将调试信息输出到一个文件中而不是标准输出中,则可以使用LD_DEBUG_OUTPUT环境变量指定一个文件名。
当请求与跟踪库搜索相关的信息时会产生很多输出,下面的例子对输出进行了删减。
LD_DEBUG=libs date
8032: find library=libc.so.6 [0]; searching
8032: search cache=/etc/ld.so.cache
8032: trying file=/lib/x86_64-linux-gnu/libc.so.6
8032:
8032:
8032: calling init: /lib/x86_64-linux-gnu/libc.so.6
8032:
8032:
8032: initialize program: date
8032:
8032:
8032: transferring control: date
8032:
Sun Feb 16 13:27:05 CST 2020
每一行开头处的10687是指所跟踪的进程的进程ID,当监控多个进程(如父进程和子进程)时会用到这个值。
在默认情况下,LD_DEBUG的输出会被写到标准错误上,但可以将一个路径名赋给环境变量LD_DEBUG_OUTPUT来将输出重定向到其他地方。
如果需要的话可以给LD_DEBUG赋多个选项,各个选项之间用逗号分隔(不能出现空格)。symbols选项(跟踪动态链接器的符号解析)的输出特别多。
LD_DEBUG对于由动态链接器隐式加载的库和使用dlopen()动态加载的库都有效。
出于安全的原因,在set-user-ID和set-setgroup-ID程序中将会忽略LD_DEBUG(自glibc 2.2.5起)。
进程间通信简介
IPC工具分类
上图总结了UNIX系统上各种通信和同步工具,并根据功能将它们分成了三类。
- 通信:这些工具关注进程之间的数据交换。
- 同步:这些进程关注进程和线程操作之间的同步。
- 信号:尽管信号的主要作用并不在此,但在特定场景下仍然可以将它作为一种同步技术。更罕见的是信号还可以作为一种通信技术:信号编号本身是一种形式的信息,并且可以在实时信号上绑定数据(一个整数或指针)。
尽管其中一些工具关注的是同步,但通用术语进程间通信(IPC)通常指代所有这些工具。
通常几个工具会提供类似的IPC功能,之所以会这样是出于下列原因。
- 不同的工具在不同的UNIX实现上各自进行演化,随后被移植到了其他UNIX系统上。如FIFO首先是在System V上实现的,而(流)socket是首先是在BSD上实现的。
- 新工具被开发出来用于弥补之前类似的工具存在的不足。如POSIX IPC工具(消息队列、信号量以及共享内存)是对较早的System V IPC工具的改进。
被分成一组的工具在一些场景中会提供完全不同的功能。如流socket可以用来在网络上通信,而FIFO则只能用来在同一机器上的进程间进行通信。
通信工具
可以将通信工具分成两类。
- 数据传输工具:区分这些工具的关键因素是写入和读取的概念。为了进行通信,一个进程将数据写入到IPC工具中,另一个进程从中读取数据。这些工具要求在用户内存和内核内存之间进行两次数据传输:一次传输是在写入的时候从用户内存到内核内存,另一次传输是在读取的时候从内核内存到用户内存。
- 共享内存:共享内存允许进程通过将数据放到由进程间共享的一块内存中以完成信息的交换。(内核通过将每个进程中的页表条目指向同一个RAM分页来实现这一功能,如图49-2所示。)一个进程可以通过将数据放到共享内存块中使得其他进程读取这些数据。由于通信无需系统调用以及用户内存和内核内存之间的数据传输,因此共享内存的速度非常快。
数据传输
可以进一步将数据传输工具分成下列类别。
- 字节流:通过管道、FIFO以及数据报socket交换的数据是一个无分隔符的字节流。每个读取操作可能会从IPC工具中读取任意数量的字节,不管写者写入的块的大小是什么。这个模型参考了传统的UNIX“文件是一个字节序列”模型。
- 消息:通过System V消息队列、POSIX消息队列以及数据报socket交换的数据是以分隔符分隔的消息。每个读取操作读取由写者写入的一整条消息,无法只读取部分消息,而把剩余部分留在IPC工具中,也无法在一个读取操作中读取多条消息。
- 伪终端:伪终端是一种在特殊情况下使用的通信工具,在64章将会介绍有关伪终端的详细信息。
数据传输工具和共享内存之间的差别包括以下几个方面。
- 尽管一个数据传输工具可能会有多个读取者,但读取操作是具有破坏性的。读取操作会消耗数据,其他进程将无法获取所消耗的数据。
在socket中可以使用MSG_PEEK标记来执行非破坏性读取。UDP(Internet domain datagram)socket允许将一条消息广播或组播到多个接收者处。 - 读取者和写者进程之间的同步是原子的。如果一个读取者试图从一个当前不包含数据的数据传输工具中读取数据,那么在默认情况下读取操作会被阻塞直至一些进程向该工具写入了数据。
共享内存
大多数现代UNIX系统提供了三种形式的共享内存:System V共享内存、POSIX共享内存以及内存映射。在后面介绍这些工具的章节中将会描述它们之间的差别。
下面是使用共享内存时的注意点。
- 尽管共享内存的通信速度更快,但速度上的优势是用来弥补需要对在共享内存上发生的操作进行同步的不足的。如当一个进程正在更新共享内存中的一个数据结构时,另一个进程就不应该试图读取这个数据结构。在共享内存中,信号量通常用来作为同步方法。
- 放入共享内存中的数据对所有共享这块内存的进程可见。(这与上面数据传输工具中介绍的破坏性读取语义不同。)
同步工具
通过同步可以防止进程执行诸如同时更新一块共享内存或同时更新文件的同一个数据块之类的操作。如果没有同步,那么这种同时更新的操作可能会导致应用程序产生错误的结果。
UNIX系统提供了下列同步工具。
- 信号量:一个信号量是一个由内核维护的整数,其值永远不会小于0。一个进程可以增加或减小一个信号量的值。如果一个进程试图将信号量的值减小到小于0,那么内核会阻塞该操作直至信号量的值增长到允许执行该操作的程度。(或者进程可以要求执行一个非阻塞操作,那么就不会发生阻塞,内核会让该操作立即返回并返回一个标示无法立即执行该操作的错误。)信号量的含义是由应用程序来确定的。一个进程减小一个信号量(如从1到0)是为了预约对某些共享资源的独占访问,在完成了资源的使用之后可以增加信号量来释放共享资源以供其他进程使用。最常用的信号量是二元信号量——一个值只能是0或1的信号量,但处理一类共享资源拥有多个实例的应用程序需要使用最大值等于共享资源数量的信号量。Linux既提供了System V信号量,又提供了POSIX信号量,它们的功能是类似的。
- 文件锁:文件锁是设计用来协调操作同一文件的多个进程的动作的一种同步方法。它也可以用来协调对其他共享资源的访问。文件锁分为两类:读(共享)锁和写(互斥)锁。任意进程都可以持有同一文件(或一个文件的某段区域)的读锁,但当一个进程持有了一个文件(或文件区域)的写锁之后,其他进程将无法获取该文件(或文件区域)上的读锁和写锁。Linux通过flock()和fcntl()系统调用来提供文件加锁工具。flock()系统调用提供了一种简单的加锁机制,允许进程将一个共享或互斥锁加到整个文件上。由于功能有限,现在已经很少使用flock()这个加锁工具了。fcntl()系统调用提供了记录加锁,允许进程在同一文件的不同区域上加上多个读锁和写锁。
- 互斥体和条件变量:这些同步工具通常用于POSIX线程。
一些UNIX实现,包括安装了能提供NPTL线程实现的glibc的Linux系统,允许在进程间共享互斥体和条件变量。SUSv3允许但并不要求实现支持进程间共享的互斥体和条件变量。所有UNIX系统都没有提供这个功能,因此很少使用它们来进行进程同步。
在执行进程间同步时通常需要根据功能需求来选择工具。当协调对文件的访问时文件记录加锁通常是最佳的选择,而对于协调对其他共享资源的访问来讲,信号量通常是更佳的选择。
通信工具也可以用来进行同步。如在44.3节中使用了一个管道来同步父进程与子进程的动作。一般来讲,所有数据传输工具都可以用来同步,只是同步操作是通过在工具中交换消息来完成的。
自内核2.6.22起,Linux通过eventfd()系统调用额外提供了一种非标准的同步机制。这个系统调用创建了一个eventfd对象,该对象拥有一个相关的由内核维护的8字节无符号整数,它返回一个指向该对象的文件描述符。向这个文件描述符中写入一个整数将会把该整数加到对象值上。当对象值为0时对该文件描述符的read()操作将会被阻塞。如果对象的值非零,那么read()会返回该值并将对象值重置为0。此外,可以使用poll()、select()以及epoll来测试对象值是否为非零,如果是非零的话就表示文件描述符可读。使用eventfd对象进行同步的应用程序必须要首先使用eventfd()创建该对象,然后调用fork()创建继承指向该对象的文件描述符的相关进程。更多细节信息可参考eventfd(2)手册。
IPC工具比较
在需要使用IPC时会发现有很多选择,读者在一开始可能会对这些选择感到迷惑。在后面介绍各个IPC工具的章节中将会把每个工具与其他类似的工具进行比较。下面介绍在确定选择何种IPC工具时通常需要考虑的事项。
IPC对象标识和打开对象的句柄
要访问一个IPC对象,进程必须要通过某种方式来标识出该对象,一旦将对象“打开”之后,进程必须要使用某种句柄来引用该打开着的对象。表43-1对各种类型的IPC工具的属性进行了总结。
工 具 类 型 | 用于识别对象的名称 | 用于在程序中引用对象的句柄 |
---|---|---|
管道 FIFO |
没有名称 路径名 |
文件描述符 文件描述符 |
UNIX domain socket Internet domain socket |
路径名 IP地址+端口号 |
文件描述符 文件描述符 |
System V消息队列 System V信号量 System V共享内存 |
System V IPC键 System V IPC键 System V IPC键 |
System V IPC标识符 System V IPC标识符 System V IPC标识符 |
POSIX消息队列 POSIX命名信号量 POSIX无名信号量 POSIX共享内存 |
POSIX IPC路径名 POSIX IPC路径名 没有名称 POSIX IPC路径名 |
mqd_t (消息队列描述符) sem_t * (信号量指针) sem_t * (信号量指针) 文件描述符 |
匿名映射 内存映射文件 |
没有名称 路径名 |
无 文件描述符 |
flock()文件锁 fcntl()文件锁 |
路径名 路径名 |
文件描述符 文件描述符 |
功能
各种IPC工具在功能上是存在差异的,因此在确定使用何种工具时需要考虑这些差异。下面首先对数据传输工具盒共享内存之间的差异进行总结。
- 数据传输工具提供了读取和写入操作,传输的数据只供一个读者进程消耗。内核会自动处理读者和写者之间的流控以及同步(这样当读者试图从当前为空的工具中读取数据时将会阻塞)。在很多应用程序设计中,这个模型都表现得很好。
- 其他应用程序设计则更适合采用共享内存的方式。一个进程通过共享内存能够使数据对共享同一内存区域的所有进程可见。通信“操作”是比
- 较简单的——进程可以像访问自己的虚拟地址空间中的内存那样访问共享内存中的数据。另一个方面,同步处理(可能还会有流控)会增加共享内存设计的复杂性。在需要维护共享状态(如共享数据结构)的应用程序中,这个模型表现得很好。
关于各种数据传输工具,下面几点是值得注意的。
- 一些数据传输工具以字节流的形式传输数据(管道、FIFO以及流socket),另一些则是面向消息的(消息队列和数据报socket)。到底选择何种方法则需要依赖于应用程序。(应用程序也可以在一个字节流工具上应用面向消息的模型,这可以通过使用分隔字符、固定长度的消息,或对整条消息长度进行编码的消息头来实现,具体可参考44.8节)。
- 与其他数据传输工具相比,System V和POSIX消息队列特有的一个特性是它们能够给消息赋一个数值类型或优先级,这样递送消息的顺序就可以与发送消息的顺序不同了。
- 管道、FIFO以及socket是使用文件描述符来实现的。这些IPC工具都支持第63章中介绍的一组I/O模型:I/O多路复用(select()和poll()系统调用)、信号驱动的I/O、以及Linux特有的epoll API。这些技术的主要优势在于它们允许应用程序同时监控多个文件描述符以判断是否可以在某些文件描述符上执行I/O操作。与之相比,System V消息队列没有使用文件描述符,因此并不支持这些技术。
在Linux上,POSIX消息队列也是使用文件描述符来实现的,因此也支持上面介绍的各种I/O技术。但SUSv3并没有规定这种行为,因此在大多数实现上并不支持这些技术。 - POSIX消息队列提供了一个通知工具,当一条消息进入了一个之前为空的队列中时可以使用它来向进程发送信号或实例化一个新线程。
- UNIX domain socket提供了一个特性允许在进程间传递文件描述符。这样一个进程就能够打开一个文件并使之对另一个本来无法访问该文件的进程可用,在61.13.3节中将会对此特性进行简要介绍。
- UDP(Internet domain datagram)socket允许一个发送者向多个接收者广播或组播一条消息。
关于进程同步工具,下面几点是值得注意的。
- 使用fcntl()加上的记录锁由加锁的进程拥有。内核使用这种所有权属性来检测死锁(两个或多个进程持有的锁会阻塞对方后续的加锁请求的场景)。如果发生了死锁,那么内核会拒绝其中一个进程的加锁请求,因此会在fcntl()调用中返回一个错误标示出死锁的发生。System V和POSIX信号量并没有所有权属性,因此内核不会为信号量进行死锁检测。
- 当使用fcntl()获得记录锁的进程终止之后会自动释放该记录锁。System V信号量提供了一个类似的特性,即“撤销”特性,但这个特性仅在部分场景中可靠(参见47.8节)。POSIX信号量并没有提供类似的特性。
网络通信
在图43-1中给出所有IPC方法中,只有socket允许进程通过网络来通信。socket一般用于两个域中:一个是UNIX domain,它允许位于同一系统上的进程进行通信;另一个是Internet domain,它允许位于通过TCP/IP网络进行连接的不同主机上的进程进行通信。通常,将一个使用UNIX domain socket进行通信的程序转换成一个使用Internet domain socket进行通信的程序只需要做出微小的改动,这样只需要对使用UNIX domain socket的应用程序做较小的改动就可以将它应用于网络场景。
可移植性
现代UNIX实现支持图43-1中的大部分IPC工具,但POSIX IPC工具(消息队列、信号量以及共享内存)的普及程度远远不如System V IPC,特别是在较早的UNIX系统上。(只有版本为2.6.x的Linux内核系列才提供了一个POSIX消息队列的实现以及对POSIX信号量的完全支持。)因此,从可移植性的角度来看,System V IPC要优于POSIX IPC。
System V IPC设计问题
System V IPC工具被设计成独立于传统的UNIX I/O模型,其结果是其中一些特性使得它的编程接口的用法更加复杂。相应的POSIX IPC工具被设计用来解决这些问题,特别是下面几点需要注意。
- System V IPC工具是无连接的,它们没有提供引用一个打开的IPC对象的句柄(类似于文件描述符)的概念。在后面的章节中有时候会将“打开”一个System V IPC对象,但这仅仅是描述进程获取一个引用该对象的句柄的简便方式。内核不会记录进程已经“打开”了该对象(与其他IPC对象不同)。这意味着内核无法维护当前使用该对象的进程的引用计数,其结果是应用程序需要使用额外的代码来知道何时可以安全地删除一个对象。
- System V IPC工具的编程接口与传统的UNIX I/O模型是不一致的(它们使用整数键值和IPC标识符,而不是路径名和文件描述符),并且这个编程接口也过于复杂了。这一点在System V信号量上表现得特别明显。
相反,内核会为POSIX IPC对象记录打开的引用数,这样就简化了何时删除对象的决策。此外,POSIX IPC提供的接口更加简单并且与传统的UNIX模型也更加一致。
可访问性
表43-2中的第二列总结了各种IPC工具的一个重要特性:权限模型控制着哪些进程能够访问对象。下面介绍各种模型的细节信息。
对于一些IPC工具(如FIFO和socket),对象名位于文件系统中,可访问性是根据相关的文件权限掩码(指定了所有者、组和其他用户的权限)来确定的(参见15.4节)。虽然System V IPC对象并不位于文件系统中,但每个对象拥有一个相关的权限掩码,其语义与文件的权限掩码类似。
一些IPC工具(管道、匿名内存映射)被标记成只允许相关进程访问。这里“相关”指通过fork()关联的。为了使两个进程能够访问同一个对象,其中一个必须要创建该对象,然后调用fork()。而fork()调用的结果就是子进程会继承引用该对象的一个句柄,这样两个进程就能够共享对象了。
POSIX的未命名信号量的可访问性是通过包含该信号量的共享内存区域的可访问性来确定的。
为了给一个文件加锁,进程必须要拥有一个引用该文件的文件描述符(即在实践中它必须要拥有打开文件的权限)。
对Internet domain socket的访问(即连接或发送数据报)没有限制。如果有需要的话,必须要在应用程序中实现访问控制。
表43-2:各种IPC工具的可访问性和持久性
工 具 类 型 | 可 访 问 性 | 持 久 性 |
---|---|---|
管道 FIFO |
仅允许相关进程 权限掩码 |
进程 进程 |
UNIX domain socket Internet domain socket |
权限掩码 任意进程 |
进程 进程 |
System V消息队列 System V信号量 System V共享内存 |
权限掩码 权限掩码 权限掩码 |
内核 内核 内核 |
POSIX消息队列 POSIX命名信号量 POSIX无名信号量 POSIX共享内存 |
权限掩码 权限掩码 相应内存的权限 权限掩码 |
内核 内核 依情况而定 内核 |
匿名映射 内存映射文件 |
仅允许相关进程 权限掩码 |
进程 文件系统 |
flock()文件锁 fcntl()文件锁 |
文件的open()操作 文件的open()操作 |
进程 进程 |
持久性
术语持久性是指一个IPC工具的生命周期。(参见表43-2中的第三列。)持久性有三种。
- 进程持久性:只要存在一个进程持有进程持久的IPC对象,那么该对象的生命周期就不会终止。如果所有进程都关闭了对象,那么与该对象的所有内核资源都会被释放,所有未读取的数据会被销毁。管道、FIFO以及socket是进程持久的IPC工具。
FIFO的数据持久性与其名称的持久性是不同的。FIFO在文件系统中拥有一个名称,当所有引用FIFO的文件描述符都被关闭之后该名称也是持久的。 - 内核持久性:只有当显式地删除内核持久的IPC对象或系统关闭时,该对象才会销毁。这种对象的生命周期与是否有进程打开该对象无关。这意味着一个进程可以创建一个对象,向其中写入数据,然后关闭该对象(或终止)。在后面某个时刻,另一个进程可以打开该对象,然后从中读取数据。具备内核持久性的工具包括System V IPC和POSIX IPC。在后面章节中用来描述这些工具的示例程序中将会使用这个属性:对于每种工具都实现一个单独的程序,在程序中创建一个对象,然后删除该对象,并执行通信或同步操作。
- 文件系统持久性:具备文件系统持久性的IPC对象会在系统重启的时候保持其中的信息,这种对象一直存在直至被显式地删除。唯一一种具备文件系统持久性的IPC对象是基于内存映射文件的共享内存。
性能
在一些场景中,不同IPC工具的性能可能存在显著的差异。但在后面的章节中一般不会对它们的性能进行比较,其原因如下。
- 在应用程序的整体性能中,IPC工具的性能的影响因素可能不是很大,并且确定选择何种IPC工具可能并不仅仅需要考虑其性能因素。
- 各种IPC工具在不同UNIX实现或Linux的不同内核中的性能可能是不同的。
- 最重要的是,IPC工具的性能可能会受到使用方式和环境的影响。相关的因素包括每个IPC操作交换的数据单元的大小、IPC工具中未读数据量可能很大、每个数据单元的交换是否需要进行进程上下文切换、以及系统上的其他负载。
如果IPC性能是至关紧要的,并且不存在应用程序在与目标系统匹配的环境中运行的性能基准,那么最好编写一个抽象软件层来向应用程序隐藏IPC工具的细节,然后在抽象层下使用不同的IPC工具来测试性能。
管道和FIFO
本章介绍管道和FIFO。管道是UNIX系统上最古老的IPC方法,它在20世纪70年代早期UNIX的第三个版本上就出现了。管道为一个常见需求提供了一个优雅的解决方案:给定两个运行不同程序(命令)的进程,在shell中如何让一个进程的输出作为另一个进程的输入呢?管道可以用来在相关进程之间传递数据(读者阅读完后面的几页之后就能够理解“相关”的含义了)。FIFO是管道概念的一个变体,它们之间的一个重要差别在于FIFO可以用于任意进程间的通信。
概述
每个shell用户都对在命令中使用管道比较熟悉,如下面这个统计一个目录中文件的数目的命令所示。
$ ls | ws -l
为执行上面的命令,shell创建了两个进程来分别执行ls和wc。(这是通过使用fork()和exec()来完成的),下图展示了这两个进程是如何使用管道的。
除了说明管道的用法之外,图44-1的另外一个目的是阐明管道这个名称的由来。可以将管道看成是一组铅管,它允许数据从一个进程流向另一个进程。
在图44-1中有一点值得注意的是两个进程都连接到了管道上,这样写入进程(ls)就将其标准输出(文件描述符为1)连接到了管道的写入端,读取进程(wc)就将其标准输入(文件描述符为0)连接到管道的读取端。实际上,这两个进程并不知道管道的存在,它们只是从标准文件描述符中读取数据和写入数据。shell必须要完成相关的工作。
一个管道是一个字节流
当讲到管道是一个字节流时意味着在使用管道时是不存在消息或消息边界的概念的。从管道中读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是什么。此外,通过管道传递的数据是顺序的——从管道中读取出来的字节的顺序与它们被写入管道的顺序是完全一样的。在管道中无法使用lseek()来随机地访问数据。
如果需要在管道中实现离散消息的概念,那么就必须要在应用程序中完成这些工作。虽然这是可行的,但如果碰到这种需求的话最好使用其他IPC机制,如消息队列和数据报socket。
从管道中读取数据
试图从一个当前为空的管道中读取数据将会被阻塞直到至少有一个字节被写入到管道中为止。如果管道的写入端被关闭了,那么从管道中读取数据的进程在读完管道中剩余的所有数据之后将会看到文件结束(即read()返回0)。
管道是单向的
在管道中数据的传递方向是单向的。管道的一段用于写入,另一端则用于读取。
在其他一些UNIX实现上——特别是那些从System V Release 4演化而来的系统——管道是双向的(所谓的流管道)。双向管道并没有在任何UNIX标准中进行规定,因此即使在提供了双向管道的实现上最好也避免依赖这种语义。作为替代方案,可以使用UNIX domain流socket对(通过使用57.5节中介绍的socketpair()系统调用来创建),它提供了一种标准的双向通信机制,并且其语义与流管道是等价的。
可以确保写入不超过PIPE_BUF字节的操作是原子的
如果多个进程写入同一个管道,那么如果它们在一个时刻写入的数据量不超过PIPE_BUF字节,那么就可以确保写入的数据不会发生相互混合的情况。
SUSv3要求PIPE_BUF至少为_POSIX_PIPE_BUF(512)。一个实现应该定义PIPE_BUF(在<limits.h>中)并/或允许调用fpathconf(fd,_PC_PIPE_BUF)来返回原子写入操作的实际上限。不同UNIX实现上的PIPE_BUF不同,如在FreeBSD 6.0其值为512字节,在Tru64 5.1上其值为4096字节,在Solaris 8上其值为5120字节。在Linux上,PIPE_BUF的值为4096。
当写入管道的数据块的大小超过了PIPE_BUF字节,那么内核可能会将数据分割成几个较小的片段来传输,在读者从管道中消耗数据时再附加上后续的数据。(write()调用会阻塞直到所有数据被写入到管道为止。)当只有一个进程向管道写入数据时(通常的情况),PIPE_BUF的取值就没有关系了。但如果有多个写入进程,那么大数据块的写入可能会被分解成任意大小的段(可能会小于PIPE_BUF字节),并且可能会出现与其他进程写入的数据交叉的现象。
只有在数据被传输到管道的时候PIPE_BUF限制才会起作用。当写入的数据达到PIPE_BUF字节时,write()会在必要的时候阻塞直到管道中的可用空间足以原子地完成操作。如果写入的数据大于PIPE_BUF字节,那么write()会尽可能地多传输数据以充满整个管道,然后阻塞直到一些读取进程从管道中移除了数据。如果此类阻塞的write()被一个信号处理器中断了,那么这个调用会被解除阻塞并返回成功传输到管道中的字节数,这个字节数会少于请求写入的字节数(所谓的部分写入)。
在Linux 2.2上,向管道写入任意数量的数据都是原子的,除非写入操作被一个信号处理器中断了。在Linux 2.4以及后续的版本上,写入数据量大于PIPE_BUF字节的所有操作都可能会与其他进程的写入操作发生交叉。(在版本号为2.2和2.4的内核中,实现管道的内核代码存在很大的差异。)
管道的容量是有限的
管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。
SUSv3并没有规定管道的存储能力。在早于2.6.11的Linux内核中,管道的存储能力与系统页面的大小是一致的(如在x86-32上是4096字节),而从Linux 2.6.11起,管道的存储能力是65,536字节。其他UNIX实现上的管道的存储能力可能是不同的。
一般来讲,一个应用程序无需知道管道的实际存储能力。如果需要防止写者进程阻塞,那么从管道中读取数据的进程应该被设计成以尽可能快的速度从管道中读取数据。
从理论上来讲,没有任何理由可以支持存储能力较小的管道无法正常工作这个结论,哪怕管道的存储能力只有一个字节。使用较大的缓冲器的原因是效率:每当写者充满管道时,内核必须要执行一个上下文切换以允许读者被调度来消耗管道中的一些数据。使用较大的缓冲器意味着需执行的上下文切换次数更少。
从Linux 2.6.35开始就可以修改一个管道的存储能力了。Linux特有的fcntl(fd, F_SETPIPE_SZ, size)调用会将fd引用的管道的存储能力修改为至少size字节。非特权进程可以将管道的存储能力修改为范围在系统的页面大小到/proc/sys/fs/pipe-max-size中规定的值之内的任何一个值。pipe-max-size的默认值是1048576字节。特权(CAP_SYS_RESOURCE)进程可以覆盖这个限制。在为管道分配空间时,内核可能会将size提升为对实现来讲更加便捷的某个值。fcntl(fd, F_GETPIPE_SZ)调用返回为管道分配的实际大小。
创建和使用管道
pipe()系统调用创建一个新管道。
#include<unistd.h>
int pipe(int filedes[2]);
成功的pipe()调用会在数组filedes中返回两个打开的文件描述符:一个表示管道的读取端(filedes[0]),另一个表示管道的写入端(filedes[1])。
与所有文件描述符一样,可以使用read()和write()系统调用来在管道上执行I/O。一旦向管道的写入端写入数据之后立即就能从管道的读取端读取数据。管道上的read()调用会读取的数据量为所请求的字节数与管道中当前存在的字节数两者之间较小的那个(但当管道为空时阻塞)。
也可以在管道上使用stdio函数(printf()、scanf()等),只需要首先使用fdopen()获取一个与filedes中的某个描述符对应的文件流即可。但在这样做的时候需要清楚在44.6节中介绍的stdio缓冲问题。
ioctl(fd, FIONREAD, &cnt)调用返回文件描述符fd所引用的管道或FIFO中未读取的字节数。其他一些实现也提供了这个特性,但SUSv3并没有对此进行规定。
在单个进程中管道的用途不多。一般来讲都是使用管道让两个进程进行通信。为了让两个进程通过管道进行连接,在调用完pipe()之后可以调用fork()。在fork()期间,子进程会继承父进程的文件描述符的副本,这样就会出现图44-3中左边那样的情形。
虽然父进程和子进程都可以从管道中读取和写入数据,但这种做法并不常见。因此,在fork()调用之后,其中一个进程应该立即关闭管道的写入端的描述符,另一个则应该关闭读取端的描述符。如,如果父进程需要向子进程传输数据,那么它就会关闭管道的读取端的描述符filedes[0],而子进程就会关闭管道的写入端的描述符filedes[1],这样就出现了图44-3中右边那样的情形。
让父进程和子进程都能够从同一个管道中读取和写入数据这种做法并不常见的一个原因是如果两个进程同时试图从管道中读取数据,那么就无法确定哪个进程会首先读取成功——两个进程竞争数据了。要防止这种竞争情况的出现就需要使用某种同步机制。但如果需要双向通信则可以使用一种更加简单的方法:创建两个管道,在两个进程之间发送数据的两个方向上各使用一个。(如果使用这种技术,那么就需要考虑死锁的问题了,因为如果两个进程都试图从空管道中读取数据或尝试向已满的管道中写入数据就可能会发生死锁。)
虽然可以有多个进程向单个管道中写入数据,但通常只存在一个写者。相反,在有些情况下让FIFO拥有多个写者是比较有用的。
从2.6.27内核开始,Linux支持一个全新的非标准系统调用pipe2()。这个系统调用执行的任务与pipe()一样,但支持额外的参数flags,这个参数可以用来修改系统调用的行为。这个系统调用支持两个标记,一个是O_CLOEXEC,它会导致内核为两个新的文件描述符启用close-on-exec标记(FD_CLOEXEC)。这个标记之所以有用的原因与在4.3.1节中介绍的open() O_CLOEXEC标记有用的原因一样。另一个是O_NONBLOCK标记,它会导致内核将底层的打开的文件描述符标记为非阻塞,这样后续的I/O操作会是非阻塞的。这样就能够在不调用fcntl()的情况下达到同样的效果了。
管道允许相关进程间的通信
目前为止本章已经介绍了如何使用管道来让父进程和子进程之间进行通信,其实管道可以用于任意两个(或更多)相关进程之间的通信,只要在创建子进程的系列fork()调用之前通过一个共同的祖先进程创建管道即可。(这就是本章开头部分所讲的“相关进程”的含义。)如管道可用于一个进程和其孙子进程之间的通信。第一个进程创建管道,然后创建子进程,接着子进程再创建第一个进程的孙子进程。管道通常用于两个兄弟进程之间的通信——它们的父进程创建了管道,然后创建两个子进程。这就是在构建管道线时shell所做的工作。
管道只能用于相关进程之间的通信这个说法存在一种例外情况。通过UNIX domain socket传递一个文件描述符使得将管道的一个文件描述符传递给一个非相关进程成为可能。
关闭未使用管道文件描述符
关闭未使用管道文件描述符不仅仅是为了确保进程不会耗尽其文件描述符的限制——这对于正确使用管道是非常重要的。下面介绍为何必须要关闭管道的读取端和写入端的未使用文件描述符。
从管道中读取数据的进程会关闭其持有的管道的写入描述符,这样当其他进程完成输出并关闭其写入描述符之后,读者就能够看到文件结束(在读完管道中的数据之后)。
如果读取进程没有关闭管道的写入端,那么在其他进程关闭了写入描述符之后,读者也不会看到文件结束,即使它读完了管道中的所有数据。相反,read()将会阻塞以等待数据,这是因为内核知道至少还存在一个管道的写入描述符打开着,即读取进程自己打开了这个描述符。从理论上来讲,这个进程仍然可以向管道写入数据,即使它已经被读取操作阻塞了。如read()可能hiu被一个向管道写入数据的信号处理器中断。(这是现实世界中的一种场景,读者在63.5.2节中将会看到。)
写入进程关闭其持有的管道的读取描述符是出于不同的原因。当一个进程试图向一个管道中写入数据但没有任何进程拥有该管道的打开着的读取描述符时,内核会向写入进程发送一个SIGPIPE信号。在默认情况下,这个信号会杀死一个进程。但进程可以捕获或忽略该信号,这样就会导致管道上的write()操作因EPIPE错误(已损坏的管道)而失败。收到SIGPIPE信号或得到EPIPE错误对于标示出管道的状态是有用的,这就是为何需要关闭管道的未使用读取描述符的原因。
注意:对被SIGPIPE处理器中断的write()的处理是特殊的。通常,当write()(或其他“慢”系统调用)被一个信号处理器中断时,这个调用会根据是否使用sigaction() SA_RESTART标记安装了处理器而自动重启或因EINTR错误而失败(参见21.5节)。对SIGPIPE的处理不同是因为自动重启write()或简单标示出write()被一个处理器中断了是毫无意义的(意味着需要手工重启write())。不管是何种处理方式,后续的write()都不会成功,因为管道仍然处于被损坏的状态。
如果写入进程没有关闭管道的读取端,那么即使在其他进程已经关闭了管道的读取端之后写入进程仍然能够向管道写入数据,最后写入进程会将数据充满整个管道,后续的写入请求会被永远阻塞。
关闭未使用文件描述符的最后一个原因是只有当所有进程中所有引用一个管道的文件描述符被关闭之后才会销毁该管道以及释放该管道占用的资源以供其他进程复用。此时,管道中所有未读取的数据都会丢失。
将管道作为一种进程同步的方法
如程序清单给出的骨架程序所示。这个程序创建了多个子进程(每个命令行参数对应一个子进程),每个子进程都完成某个动作,在本例中则是睡眠一段时间。父进程等待直到所有子进程完成了自己的动作为止。
为了执行同步,父进程在创建子进程②之前构建了一个管道①。每个子进程会继承管道的写入端的文件描述符并在完成动作之后关闭这些描述符③。当所有子进程都关闭了管道的写入端的文件描述符之后,父进程在管道上的read()⑤就会结束并返回文件结束(0)。这时,父进程就能够做其他工作了。(注意在父进程中关闭管道的未使用写入端④对于这项技术的正常运转是至关重要的,否则父进程在试图从管道中读取数据时会被永远阻塞。)
#include "curr_time.h" /* Declaration of currTime() */
#include "tlpi_hdr.h"
int
main(int argc, char *argv[])
{
int pfd[2]; /* Process synchronization pipe */
int j, dummy;
if (argc < 2 || strcmp(argv[1], "--help") == 0)
usageErr("%s sleep-time...\n", argv[0]);
setbuf(stdout, NULL); /* Make stdout unbuffered, since we
terminate child with _exit() */
printf("%s Parent started\n", currTime("%T"));
if (pipe(pfd) == -1)
errExit("pipe");
for (j = 1; j < argc; j++) {
switch (fork()) {
case -1:
errExit("fork %d", j);
case 0: /* Child */
if (close(pfd[0]) == -1) /* Read end is unused */
errExit("close");
/* Child does some work, and lets parent know it's done */
sleep(getInt(argv[j], GN_NONNEG, "sleep-time"));
/* Simulate processing */
printf("%s Child %d (PID=%ld) closing pipe\n",
currTime("%T"), j, (long) getpid());
if (close(pfd[1]) == -1)
errExit("close");
/* Child now carries on to do other things... */
_exit(EXIT_SUCCESS);
default: /* Parent loops to create next child */
break;
}
}
/* Parent comes here; close write end of pipe so we can see EOF */
if (close(pfd[1]) == -1) /* Write end is unused */
errExit("close");
/* Parent may do other work, then synchronizes with children */
if (read(pfd[0], &dummy, 1) != 0)
fatal("parent didn't get EOF");
printf("%s Parent ready to go\n", currTime("%T"));
/* Parent can now carry on to do other things... */
exit(EXIT_SUCCESS);
}
$ ./pipe_sync 4 2 6
08:22:16 Parent started
08:22:18 Child 2 (PID=2445) closing pipe
08:22:20 Child 1 (PID=2444) closing pipe
08:22:22 Child 3 (PID=2446) closing pipe
08:22:18 Parent ready to go
与前面使用信号来同步相比,使用管道同步具备一个优势:它可以用来协调一个进程的动作使之与多个其他(相关)进程匹配。而多个(标准)信号无法排队的事实使得信号不适用于这种情形。(相反,信号的优势是它可以被一个进程广播到进程组中的所有成员处。)
其他同步结构也是可行的(如使用多个管道)。此外,还可以对这项技术进行扩展,即不关闭管道,每个子进程向管道写入一条包含其进程ID和一些状态信息的消息。或者每个子进程可以向管道写入一个字节。父进程可以计数和分析这些消息。这种方法考虑到了子进程意外终止而不是显式地关闭管道的情形。
使用管道连接过滤器
TODO.
内存映射
TODO.
虚拟内存操作
TODO.
SOCKET:介绍
socket是一种IPC方法,它允许位于同一主机(计算机)或使用网络连接起来的不同主机上的应用程序之间交换数据。第一个被广泛接受的socket API实现于1983年,出现在了4.2BSD中,实际上这组API已经被移植到了所有UNIX实现以及其他大多数操作系统上了。
socket API是在POSIX.1g中进行正式规定的,它作为标准草案在经历了10年之后于2000年被正式认可。现在它已经被SUSv3所取代了。
概述
在一个典型的客户端/服务器场景中,应用程序使用socket进行通信的方式如下。
- 各个应用程序创建一个socket。socket是一个允许通信的“设备”,两个应用程序都需要用到它。
- 服务器将自己的socket绑定到一个众所周知的地址(名称)上使得客户端能够定位到它的位置。
使用socket()系统调用能够创建一个socket,它返回一个用来在后续系统调用中引用该socket的文件描述符。
fd = socket(domain,type,protocol);
protocol参数总是被指定为0。
通信domain
socket存在于一个通信domain中,它确定:
- 识别出一个socket的方法(即socket“地址”的格式);
- 通信范围(即是在位于同一主机上的应用程序之间还是在位于使用一个网络连接起来的不同主机上的应用程序之间)。
现代操作系统至少支持下列domain:
- UNIX (AF_UNIX) domain允许在同一主机上的应用程序之间进行通信。(POSIX.1g使用名称AF_LOCAL作为AF_UNIX的同义词,但SUSv3并没有使用这个名称。)
- IPv4 (AF_INET) domain允许在使用因特网协议第4版(IPv4)网络连接起来的主机上的应用程序之间进行通信。
- IPv6 (AF_INET6) domain允许在使用因特网协议第6版(IPv6)网络连接起来的主机上的应用程序之间进行通信。尽管IPv6被设计成了IPv4接任者,但目前后一种协议仍然是使用最广的协议。
在一些代码中读者可能会看到名称诸如PF_UNIX而不是AF_UNIX的常量。在这种上下文中,AF表示“地址族(address family)”,PF表示“协议族(protocol family)”。在一开始的时候,设计人员相信单个协议族可以支持多个地址族。但在实践中,没有哪一个协议族能够支持多个已经被定义的地址族,并且所有既有实现都将PF_常量定义成对应的AF_常量的同义词。(SUSv3规定了AF_常量,但没有规定PF_常量。)在本书中会一直使用AF_常量。
socket domain
Domain | 执行的通信 | 应用程序间的通信 | 地址格式 | 地址结构 |
---|---|---|---|---|
AF_UNIX | 内核中 | 同一主机 | 路径名 | sockaddr_un |
AF_INET | 通过IPv4 | 通过IPv4网络连接起来的主机 | 32位IPv4地址+16位端口号 | sockaddr_in |
AF_INET6 | 通过IPv6 | 通过IPv6网络连接起来的主机 | 128位IPv6地址+16位端口号 | sockaddr_in6 |
socket类型
每个socket实现都至少提供了两种socket:流和数据报。这两种socket类型在UNIX和Internet domain中都得到了支持。
socket类型及其属性
属性 | 流 | 数据报 |
---|---|---|
可靠地递送? | 是 | 否 |
消息边界保留? | 否 | 是 |
面向连接? | 是 | 否 |
流socket(SOCK_STREAM)提供了一个可靠的双向的字节流通信信道。在这段描述中的术语的含义如下。
- 可靠的:表示可以保证发送者传输的数据会完整无缺地到达接收应用程序(假设网络链接和接收者都不会崩溃)或收到一个传输失败的通知。
- 双向的:表示数据可以在两个socket之间的任意方向上传输。
- 字节流:表示与管道一样不存在消息边界的概念。
一个流socket类似于使用一对允许在两个应用程序之间进行双向通信的管道,它们之间的差别在于(Internet domain)socket允许在网络上进行通信。
流socket的正常工作需要一对相互连接的socket,因此流socket通常被称为面向连接的。术语“对等socket”是指连接另一端的socket,“对等地址”表示该socket的地址,“对等应用程序”表示利用这个对等socket的应用程序。有些时候,术语“远程”(或外部)是作为对等的同义词使用。类似地,有些时候术语“本地”被用来指连接的这一端上的应用程序、socket或地址。一个流socket只能与一个对等socket进行连接。
数据报socket(SOCK_DGRAM)允许数据以被称为数据报的消息的形式进行交换。在数据报socket中,消息边界得到了保留,但数据传输是不可靠的。消息的到达可能是无序的、重复的或者根本就无法到达。
数据报socket是更一般的无连接socket概念的一个示例。与流socket不同,一个数据报socket在使用时无需与另一个socket连接。
在Internet domain中,数据报socket使用了用户数据报协议(UDP),而流socket则(通常)使用了传输控制协议(TCP)。一般来讲,在称呼这两种socket时不会使用术语“Internet domain数据报socket”和“Internet domain流socket”,而是分别使用术语“UDP socket”和“TCP socket”。
socket系统调用
关键的socket系统调用包括以下几种。
- socket()系统调用创建一个新socket。
- bind()系统调用将一个socket绑定到一个地址上。通常,服务器需要使用这个调用来将其socket绑定到一个众所周知的地址上使得客户端能够定位到该socket上。
- listen()系统调用允许一个流socket接受来自其他socket的接入连接。
- accept()系统调用在一个监听流socket上接受来自一个对等应用程序的连接,并可选地返回对等socket的地址。
- connect()系统调用建立与另一个socket之间的连接。
在大多数Linux架构上(除了Alpha和IA-64),所有这些socket系统调用实际上被实现成了通过单个系统调用socketcall()进行多路复用的库函数。(这是Linux socket实现的最初的开发工作,作为一个单独的项目的产物。)但在本书中将所有这些函数都称为系统调用,因为它们在最初的BSD实现以及其他很多同时代的UNIX实现上是被实现成系统调用的。
socket I/O可以使用传统的read()和write()系统调用或使用一组socket特有的系统调用(如send()、recv()、sendto()以及recvfrom())来完成。在默认情况下,这些系统调用在I/O操作无法被立即完成时会阻塞。通过使用fcntl() F_SETFL操作来启用O_NONBLOCK打开文件状态标记可以执行非阻塞I/O。
在Linux上可以通过调用ioctl(fd, FIONREAD, &cnt)来获取文件描述符fd引用的流socket中可用的未读字节数。对于数据报socket来讲,这个操作会返回下一个未读数据报中的字节数(如果下一个数据报的长度为零的话就返回零)或在没有未决数据报的情况下返回0。这种特性没有在SUSv3中予以规定。
创建一个socket:socket()
socket()系统调用创建一个新socket。
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain参数指定了socket的通信domain。type参数指定了socket类型。这个参数通常在创建流socket时会被指定为SOCK_STREAM,而在创建数据报socket时会被指定为SOCK_DGRAM。
protocol参数在本书描述的socket类型中总会被指定为0。在一些socket类型中会使用非零的protocol值,但本书并没有对这些socket类型进行描述。如在裸socket(SOCK_RAW)中会将protocol指定为IPPROTO_RAW。
socket()在成功时返回一个引用在后续系统调用中会用到的新创建的socket的文件描述符。
从内核2.6.27开始,Linux为type参数提供了第二种用途,即允许两个非标准的标记与socket类型取OR。SOCK_CLOEXEC标记会导致内核为新文件描述符启用close-on-exec标记(FD_CLOEXEC)。这个标记之所以有用的原因与open() O_CLOEXEC标记有用的原因是一样的。SOCK_NONBLOCK标记导致内核在底层打开着的文件描述符上设置O_NONBLOCK标记,这样后面在该socket上发生的I/O操作就变成非阻塞了,从而无需通过调用fcntl()来取得同样的结果。
将socket绑定到地址:bind()
bind()系统调用将一个socket绑定到一个地址上。
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd参数是在上一个socket()调用中获得的文件描述符。addr参数是一个指针,它指向了一个指定该socket绑定到的地址的结构。传入这个参数的结构的类型取决于socket domain。addrlen参数指定了地址结构的大小。addrlen参数使用的socklen_t数据类型在SUSv3被规定为一个整数类型。
一般来讲,会将一个服务器的socket绑定到一个众所周知的地址——即一个固定的与服务器进行通信的客户端应用程序提前就知道的地址。
除了将一个服务器的socket绑定到一个众所周知的地址之外还存在其他做法。例如,对于一个Internet domain socket来讲,服务器可以不调用bind()而直接调用listen(),这将会导致内核为该socket选择一个临时端口。之后服务器可以使用getsockname()来获取socket的地址。在这种场景中,服务器必须要发布其地址使得客户端能够知道如何定位到服务器的socket。这种发布可以通过向一个中心目录服务应用程序注册服务器的地址来完成,之后客户端可以通过这个服务来获取服务器的地址。(如Sun RPC使用了自己的portmapper服务器来解决这个问题。)当然,目录服务应用程序的socket必须要位于一个众所周知的地址上。
通用socket地址结构:struct sockaddr
传入bind()的addr和addrlen参数比较复杂,有必要对其做进一步解释。从表56-1中可以看出每种socket domain都使用了不同的地址格式。如UNIX domain socket使用路径名,而Internet domain socket使用了IP地址和端口号。对于各种socket domain都需要定义一个不同的结构类型来存储socket地址。然而由于诸如bind()之类的系统调用适用于所有socket domain,因此它们必须要能够接受任意类型的地址结构。为支持这种行为,socket API定义了一个通用的地址结构struct sockaddr。这个类型的唯一用途是将各种domain特定的地址结构转换成单个类型以供socket系统调用中的各个参数使用。sockaddr结构通常被定义成如下所示的结构。
struct sockaddr {
unsigned short sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};
这个结构是所有domain特定的地址结构的模板,其中每个地址结构均以与sockaddr结构中sa_family字段对应的family字段打头。(sa_family_t数据类型在SUSv3中被规定成一个整数类型。)通过family字段的值足以确定存储在这个结构的剩余部分中的地址的大小和格式了。
一些UNIX实现还在sockaddr结构中定义了一个额外的字段sa_len,它指定了这个结构的总大小。SUSv3并没有要求这个字段,在socket API的Linux实现中也不存在这个字段。
如果定义了_GNU_SOURCE特性测试宏,那么glibc将使用一个gcc扩展在<sys/socket.h>中定义各个socket系统调用的原型,从而就无需进行(struct sockaddr *)转换了,但依赖这个特性是不可移植的(在其他系统上将会导致编译警告)。
流socket
流socket的运作与电话系统类似。
1. socket()系统调用将会创建一个socket,这等价于安装一个电话。为使两个应用程序能够通信,每个应用程序都必须要创建一个socket。
2. 通过一个流socket通信类似于一个电话呼叫。一个应用程序在进行通信之前必须要将其socket连接到另一个应用程序的socket上。两个socket的连接过程如下。
(a)一个应用程序调用bind()以将socket绑定到一个众所周知的地址上,然后调用listen()通知内核它接受接入连接的意愿。这一步类似于已经有了一个为众人所知的电话号码并确保打开了电话,这样人们就可以打进电话了。
(b)其他应用程序通过调用connect()建立连接,同时指定需连接的socket的地址。这类似于拨某人的电话号码。
(c)调用listen()的应用程序使用accept()接受连接。这类似于在电话响起时拿起电话。如果在对等应用程序调用connect()之前执行了accept(),那么accept()就会阻塞(“等待电话”)。
3. 一旦建立了一个连接之后就可以在应用程序之间(类似于两路电话会话)进行双向数据传输直到其中一个使用close()关闭连接为止。通信是通过传统的read()和write()系统调用或通过一些提供了额外功能的socket特定的系统调用(如send()和recv())来完成的。
主动和被动socket
流socket通常可以分为主动和被动两种。
- 在默认情况下,使用socket()创建的socket是主动的。一个主动的socket可用在connect()调用中来建立一个到一个被动socket的连接。这种行为被称为执行一个主动的打开。
- 一个被动socket(也被称为监听socket)是一个通过调用listen()以被标记成允许接入连接的socket。接受一个接入连接通常被称为执行一个被动的打开。
在大多数使用流socket的应用程序中,服务器会执行被动式打开,而客户端会执行主动式打开。在后面的小节中将会假设这种场景,因此不会再说“执行主动socket打开的应用程序”,而是直接说“客户端”。类似地,“服务器”等价于“执行被动socket打开的应用程序”。
监听接入连接:listen()
listen()系统调用将文件描述符sockfd引用的流socket标记为被动。这个socket后面会被用来接受来自其他(主动的)socket的连接。
#include<sys/socket.h>
int listen(int sockfd, int backlog);
无法在一个已连接的socket(即已经成功执行connect()的socket或由accept()调用返回的socket)上执行listen()。
要理解backlog参数的用途首先需要注意到客户端可能会在服务器调用accept()之前调用connect()。这种情况是有可能会发生的,如服务器可能正忙于处理其他客户端。这将会产生一个未决的连接。
内核必须要记录所有未决的连接请求的相关信息,这样后续的accept()就能够处理这些请求了。backlog参数允许限制这种未决连接的数量。在这个限制之内的连接请求会立即成功。(对于TCP socket来讲事情就稍微有点复杂了。)之外的连接请求就会阻塞直到一个未决的连接被接受(通过accept()),并从未决连接队列删除为止。
SUSv3允许一个实现为backlog的可取值规定一个上限并允许一个实现静默地将backlog值向下舍入到这个限制值。SUSv3规定实现应该通过在<sys/socket.h>中定义SOMAXCONN常量来发布这个限制。在Linux上,这个常量的值被定义成了128。但从内核2.4.25起,Linux允许在运行时通过Linux特有的/proc/sys/net/core/somaxconn文件来调整这个限制。(在早期的内核版本中,SOMAXCONN限制是不可变的。)
在最初的BSD socket实现中,backlog的上限是5,并且在较早的代码中可以看到这个数值。所有现代实现允许为backlog指定更高的值,这对于使用TCP socket服务大量客户的网络服务器来讲是有必要的。
接受连接:accept()
理解accept()的关键点是它会创建一个新socket,并且正是这个新socket会与执行connect()的对等socket进行连接。accept()调用返回的函数结果是已连接的socket的文件描述符。监听socket(sockfd)会保持打开状态,并且可以被用来接受后续的连接。一个典型的服务器应用程序会创建一个监听socket,将其绑定到一个众所周知的地址上,然后通过接受该socket上的连接来处理所有客户端的请求。
传入accept()的剩余参数会返回对端socket的地址。addr参数指向了一个用来返回socket地址的结构。这个参数的类型取决于socket domain(与bind()一样)。
addrlen参数是一个值-结果参数。它指向一个整数,在调用被执行之前必须要将这个整数初始化为addr指向的缓冲区的大小,这样内核就知道有多少空间可用于返回socket地址了。当accept()返回之后,这个整数会被设置成实际被复制进缓冲区中的数据的字节数。
如果不关心对等socket的地址,那么可以将addr和addrlen分别指定为NULL和0。
从内核2.6.28开始,Linux支持一个新的非标准系统调用accept4()。这个系统调用执行的任务与accept()相同,但支持一个额外的参数flags,而这个参数可以用来改变系统调用的行为。目前系统支持两个标记:SOCK_CLOEXEC和SOCK_NONBLOCK。SOCK_CLOEXEC标记导致内核在调用返回的新文件描述符上启用close-on-exec标记(FD_CLOEXEC)。这个标记之所以有用的原因与4.3.1节中描述的open() O_CLOEXEC标记有用的原因是一样的。SOCK_NONBLOCK标记导致内核在底层打开着的文件描述上启用O_NONBLOCK标记,这样在该socket上发生的后续I/O操作将会变成非阻塞了,从而无需通过调用fcntl()来取得同样的结果。
连接到对等socket:connect()
connect()系统调用将文件描述符sockfd引用的主动socket连接到地址通过addr和addrlen指定的监听socket上。
#include<sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
addr和addrlen参数的指定方式与bind()调用中对应参数的指定方式相同。
如果connect()失败并且希望重新进行连接,那么SUSv3规定完成这个任务的可移植的方法是关闭这个socket,创建一个新socket,在该新socket上重新进行连接。
流socket I/O
一对连接的流socket在两个端点之间提供了一个双向通信信道。连接流socket上I/O的语义与管道上I/O的语义类似。
要执行I/O需要使用read()和write()系统调用(或在61.3节中描述的socket特有的send()和recv()调用)。由于socket是双向的,因此在连接的两端都可以使用这两个调用。
一个socket可以使用close()系统调用来关闭或在应用程序终止之后关闭。之后当对等应用程序试图从连接的另一端读取数据时将会收到文件结束(当所有缓冲数据都被读取之后)。如果对等应用程序试图向其socket写入数据,那么它就会收到一个SIGPIPE信号,并且系统调用会返回EPIPE错误处理这种情况的常见方式是忽略SIGPIPE信号并通过EPIPE错误找出被关闭的连接。
连接终止:close()
终止一个流socket连接的常见方式是调用close()。如果多个文件描述符引用了同一个socket,那么当所有描述符被关闭之后连接就会终止。
假设在关闭一个连接之后,对等应用程序崩溃或没有读取或错误处理了之前发送给它的数据。在这种情况下就无法知道已经发生了一个错误。如果需要确保数据被成功地读取和处理,那么就必须要在应用程序中构建某种确认协议。这通常由一个从对等应用程序传过来的显式的确认消息构成。
shutdown()系统调用,它为如何关闭一个流socket连接提供了更加精细的控制。
数据报socket
数据报socket的运作类似于邮政系统。
1. socket()系统调用等价于创建一个邮箱。(这里假设一个系统与一些国家的农村中的邮政服务类似,取信和送信都是在邮箱中发生的。)所有需要发送和接收数据报的应用程序都需要使用socket()创建一个数据报socket。
2. 为允许另一个应用程序发送其数据报(信),一个应用程序需要使用bind()将其socket绑定到一个众所周知的地址上。一般来讲,一个服务器会将其socket绑定到一个众所周知的地址上,而一个客户端会通过向该地址发送一个数据报来发起通信。(在一些domain中——特别是UNIX domain——客户端如果想要接受服务器发送来的数据报的话可能还需要使用bind()将一个地址赋给其socket。)
3. 要发送一个数据报,一个应用程序需要调用sendto(),它接收的其中一个参数是数据报发送到的socket的地址。这类似于将收信人的地址写到信件上并投递这封信。
4. 为接收一个数据报,一个应用程序需要调用recvfrom(),它在没有数据报到达时会阻塞。由于recvfrom()允许获取发送者的地址,因此可以在需要的时候发送一个响应。(这在发送者的socket没有绑定到一个众所周知的地址上时是有用的,客户端通常是会碰到这种情况。)这里对这个比喻做了一点延伸,因为已投递的信件上是无需标记上发送者的地址的。
5. 当不再需要socket时,应用程序需要使用close()关闭socket。
与邮政系统一样,当从一个地址向另一个地址发送多个数据报(信)时是无法保证它们按照被发送的顺序到达的,甚至还无法保证它们都能够到达。数据报还新增了邮政系统所不具备的一个特点:由于底层的联网协议有时候会重新传输一个数据包,因此同样的数据包可能会多次到达。
交换数据报:recvfrom和sendto()
recvfrom()和sendto()系统调用在一个数据报socket上接收和发送数据报。
#include<sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
这两个系统调用的返回值和前三个参数与read()和write()中的返回值和相应参数是一样的。
第四个参数flags是一个位掩码,它控制着了socket特定的I/O特性。
src_addr和addrlen参数被用来获取或指定与之通信的对等socket的地址。
对于recvfrom()来讲,src_addr和addrlen参数会返回用来发送数据报的远程socket的地址。(这些参数类似于accept()中的addr和addrlen参数,它们返回已连接的对等socket的地址。)src_addr参数是一个指针,它指向了一个与通信domain匹配的地址结构。与accept()一样,addrlen是一个值-结果参数。在调用之前应该将addrlen初始化为src_addr指向的结构的大小;在返回之后,它包含了实际写入这个结构的字节数。
如果不关心发送者的地址,那么可以将src_addr和addrlen都指定为NULL。在这种情况下,recvfrom()等价于使用recv()来接收一个数据报。也可以使用read()来读取一个数据报,这等价于在使用recv()时将flags参数指定为0。
不管length的参数值是什么,recvfrom()只会从一个数据报socket中读取一条消息。如果消息的大小超过了length字节,那么消息会被静默地截断为length字节。
如果使用了recvmsg()系统调用,那么通过返回的msghdr结构中的msg_flags字段中的MSG_TRUNC标记来找出被截断的数据报,具体细节请参考recvmsg(2)手册。
对于sendto()来讲,dest_addr和addrlen参数指定了数据报发送到的socket。这些参数的使用方式与connect()中相应参数的使用方式是一样的。dest_addr参数是一个与通信domain匹配的地址结构,它会被初始化成目标socket的地址。addrlen参数指定了addr的大小。
在Linux上可以使用sendto()发送长度为0的数据报,但不是所有的UNIX实现都允许这样做的。
在数据报socket上使用connect()
尽管数据报socket是无连接的,但在数据报socket上应用connect()系统调用仍然是起作用的。在数据报socket上调用connect()会导致内核记录这个socket的对等socket的地址。术语已连接的数据报socket就是指此种socket。术语非连接的数据报socket是指那些没有调用connect()的数据报socket(即新数据报socket的默认行为)。
当一个数据报socket已连接之后:
- 数据报的发送可在socket上使用write()(或send())来完成并且会自动被发送到同样的对等socket上。与sendto()一样,每个write()调用会发送一个独立的数据报;
- 在这个socket上只能读取由对等socket发送的数据报。
注意connect()的作用对数据报socket是不对称的。上面的论断只适用于调用了connect()数据报socket,并不适用于它连接的远程socket(除非对等应用程序在其socket上也调用了connect())。
通过再发起一个connect()调用可以修改一个已连接的数据报socket的对等socket。此外,通过指定一个地址族(如UNIX domain中的sun_family字段)为AF_UNSPEC的地址结构还可以解除对等关联关系。但需要注意的是,其他很多UNIX实现并不支持将AF_UNSPEC用于这种用途。
SUSv3在解除对等关系方面的论断是比较模糊的,它只是声称通过调用一个指定了“空地址”的connect()调用可以重置一个连接,并没有定义那样一个术语。SUSv4则明确规定了需要使用AF_UNSPEC。
为一个数据报socket设置一个对等socket,这种做法的一个明显优势是在该socket上传输数据时可以使用更简单的I/O系统调用,即无需使用指定了dest_addr和addrlen参数的sendto(),而只需要使用write()即可。设置一个对等socket主要对那些需要向单个对等socket(通常是某种数据报客户端)发送多个数据报的应用程序是比较有用的。
在一些TCP/IP实践中,将一个数据报socket连接到一个对等socket能够带来性能上的提升(([Stevens et al., 2004])。在Linux上,连接一个数据报socket能对性能产生些许差异。
SOCKET:UNIX DOMAIN
本章将介绍允许位于同一主机系统上的进程之间相互通信的UNIX domain socket的用法,包括UNIX domain中流socket和数据报socket的使用,如何使用文件权限来控制对UNIX domain socket的访问,如何使用socketpair()创建一对相互连接的UNIX domain socket,以及Linux抽象socket名空间。
UNIX domain socket地址:struct sockaddr_un
在UNIX domain中,socket地址以路径名来表示,domain特定的socket地址结构的定义如下所示。
struct sockaddr_un{
sa_family_t sun_family; /* AF_LOCAL */
char sun_path[108]; /* null-terminated pathname */
};
ockaddr_un结构中字段的sun_前缀与Sun Microsystems没有任何关系,它是根据socket unix而来的。
SUSv3并没有规定sun_path字段的大小。早期的BSD实现使用108和104字节,而一个稍微现代一点的实现(HP-UX 11)则使用了92字节。可移植的应用程序在编码时应该采用最低值,并且在向这个字段写入数据时使用snprintf()或strncpy()以避免缓冲区溢出。
为将一个UNIX domain socket绑定到一个地址上,需要初始化一个sockaddr_un结构,然后将指向这个结构的一个(转换)指针作为addr参数传入bind()并将addrlen指定为这个结构的大小.
绑定一个UNIX domain socket:
const char *SOCKNAME = "/tmp/mysock";
int sfd;
struct sockaddr_un addr;
sfd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sfd == -1)
errExit("socket");
/* Create socket */
memset(&addr, 0, sizeof(struct sockaddr_un));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, SOCKNAME, sizeof(addr.sun_path) - 1);
if (bind(sfd, (struct sockaddr *) &addr, sizeof(struct sockaddr_un)) == -1)
errExit("bind");
程序清单使用memset()调用来确保结构中所有字段的值都为0。(后面的strncpy()调用利用这一点并将其最后一个参数指定为sun_path字段的大小减一来确保这个字段总是拥有一个结束的null字节。)使用memset()将整个结构清零而不是一个字段一个字段地进行初始化能够确保一些实现提供的所有非标准字段都会被初始化为0。
从BSD衍生出来的bzero()函数是一个可以用来取代memset()对一个结构的内容进行清零的函数。SUSv3规定了bzero()以及相关的bcopy()(与memmove()类似),但将这两个函数标记成了LEGACY并指出首选使用memset()和memmove()。SUSv4则删除了与bzero()和bcopy()有关的规范。
当用来绑定UNIX domain socket时,bind()会在文件系统中创建一个条目。(因此作为socket路径名的一部分的目录需要可访问和可写。)文件的所有权将根据常规的文件创建规则来确定。这个文件会被标记为一个socket。当在这个路径名上应用stat()时,它会在stat结构的st_mode字段中的文件类型部分返回值S_IFSOCK。当使用ls –l列出时,UNIX domain socket在第一列将会显示类型s,而ls –F则会在socket路径名后面附加上一个等号(=)。
尽管UNIX domain socket是通过路径名来标识的,但在这些socket上发生的I/O无须对底层设备进行操作。
有关绑定一个UNIX domain socket方面还需要注意以下几点。
- 无法将一个socket绑定到一个既有路径名上(bind()会失败并返回EADDRINUSE错误)。
- 通常会将一个socket绑定到一个绝对路径名上,这样这个socket就会位于文件系统中的一个固定地址处。当然,也可以使用一个相对路径名,但这种做法并不常见,因为它要求想要connect()这个- - socket的应用程序知道执行bind()的应用程序的当前工作目录。
- 一个socket只能绑定到一个路径名上,相应地,一个路径名只能被一个socket绑定。
- 无法使用open()打开一个socket。
- 当不再需要一个socket时可以使用unlink()(或remove())删除其路径名条目(通常也应该这样做)。
在本章给出的大多数示例程序中,将会把UNIX domain socket绑定到/tmp目录下的一个路径名上,因为通常这个目录在所有系统上都是存在并且可写的。这样读者就能够很容易地运行这些程序而无需编辑这些socket路径名了。但需要知道的是这通常不是一种优秀的设计技术。正如在38.7节中指出的那样,在诸如/tmp此类公共可写的目录中创建文件可能会导致各种各样的安全问题。例如在/tmp中创建一个名字与应用程序socket的路径名一样的路径名之后就能够完成一个简单的拒绝服务攻击了。现实世界中的应用程序应该将UNIX domain socket bind()到一个采取了恰当的安全保护措施的目录中的绝对路径名上。
UNIX domain中的流socket
TODO.
UNIX domain中的数据报socket
TODO.
UNIX domain socket权限
TODO.
创建互联socket对:socketpair()
TODO.
Linux抽象socket名空间
TODO.
SOCKET:TCP/IP网络基础
互联网
互联网络(internetwork),或更一般地,互联网(internet,小写的i),会将不同的计算机网络连接起来并允许位于网络中的主机相互之间进行通信。换句话说,一个互联网是由计算机网络组成的一个网络。术语子网络,或子网,用来指组成因特网的其中一个网络。互联网的目标是隐藏不同物理网络的细节以便向互联网络中的所有主机呈现一个统一的网络架构,例如,这意味着可以使用单个地址格式来标识互联网上的所有主机。
尽管已经设计出了多种互联网互联协议,但TCP/IP已经成了使用为最广泛的协议套件了,它甚至已经取代了之前在局域网和广域网中常见的私有联网协议了。术语Internet(大写的I)被用来指将全球成千上万的计算机连接起来的TCP/IP互联网。
第一个被广泛使用的TCP/IP实现出现在了1983年的4.2BSD中。一些TCP/IP实现是直接从BSD代码演化而来的,其他的实现(包括Linux)则是从零开始编写的,但它们在定义TCP/IP的操作时将BSD代码的操作当成了参考标准。
TCP/IP是从美国国防部先进研究项目局(Advanced Research Projects Agency,ARPA,之后又被称为DARPA,其中D表示Defense)资助的一个项目中成长出来的,该项目主要是想设计出一个计算机联网架构以供早期的广域网ARPANET使用。在20世纪70年代,一个新的协议族被设计出来供ARPANET使用。准确地讲,这些协议被称为DARPA因特网协议套件,但它们通常被称为TCP/IP协议套件,或者简单地被称为TCP/IP。
网页https://www.isoc.org/internet/history/brief.shtml 提供了与Internet和TCP/IP有关的一段简短的历史。
一台路由器拥有多个网络接口,每个接口都连接到一个子网上。更通用的术语“多宿主机”用来指拥有多个网络接口的任意主机——不必是一台路由器。(另一种描述路由器的方式是说它是将包从一个子网转发到另一个子网的一台多宿主机。)一个多宿主机的各个接口上的网络地址是不同的(即其连接的各个子网的地址是不同的)。
联网协议和层
一个联网协议是定义如何在一个网络上传输信息的一组规则。联网协议通常会被组织成一系列的层,其中每一层都构建于下层之上并提供特性以供上层使用。
TCP/IP协议套件是一个分层联网协议(图58-2),它包括因特网协议(IP)和位于其上层的各个协议层。(实现这些层的代码通常被称为协议栈。)名字TCP/IP是从传输控制协议(TCP)是使用最为广泛的传输层协议这样一个事实而得出来的。
在图58-2中省略了其他一些TCP/IP协议,因为它们与本章的主题无关。地址解析协议(ARP)关注的是如何将因特网地址映射到硬件(如以太网)地址。因特网控制消息协议(ICMP)用来在网络中传输错误和控制信息。(ping和traceroute程序使用的是ICMP协议,人们通常使用ping来检查一台特定的主机是否存活以及是否在TCP/IP网络中可见,使用traceroute来跟踪一个IP包在网络中的传输路径。)主机和路由器使用因特网组管理协议(IGMP)来支持IP数据报的多播。
协议分层如此强大和灵活的其中一个原因是透明——每一个协议层都对上层隐藏下层的操作和复杂性,如一个使用TCP的应用程序只需要使用标准的socket API并清楚自己正在使用一项可靠的字节流传输服务,而无需理解TCP操作的细节。(在61.9节中介绍socket选项时将会看到严格地讲这一论断并不总是正确的,应用程序偶尔也需要弄清楚底层传输协议的操作细节。)应用程序也无需知道IP和数据链路层的操作细节。从应用程序的角度来讲,它就像是通过socket API直接与其他层进行通信了,如图58-3所示,其中虚横线表示对应应用程序之间的虚拟通信路径以及两个主机上的TCP和IP实体。
封装
封装是分层联网协议中的一个重要的原则。图58-4给出了TCP/IP协议层中的封装。封装中的关键概念是低层会将从高层向低层传递的信息(如应用程序数据、TCP段、IP数据报)当成不透明的数据来处理。换句话说,低层不会尝试对高层发送过来的信息进行解释,而只会将这些信息放到低层所使用的包中并在将这个包向下传递到低层之前添加自身这一层的头信息。当数据从低层传递到高层时将会进行一个逆向的解包过程。
封装的概念还延伸到了数据链路层,其中IP数据报会被封装进网络帧中,但在图58-4中并没有显示出这些。封装可能还会延伸到应用层中,其中应用程序可能会按照自己的方式对数据进行打包。
数据链路层
图58-2中的最低层是数据链路层,它由设备驱动和到底层物理媒介(如电话线、同轴电缆、或光纤)的硬件接口(网卡)构成。数据链路层关注的是在一个网络的物理链接上传输数据。
要传输数据,数据链路层需要将网络层传递过来的数据报封装进被称为帧的一个一个单元。除了需要传输的数据之外,每个帧都会包含一个头,如头中可能包含了目标地址和帧的大小。数据链路层在物理链接上传输帧并处理来自接收者的确认。(不是所有的数据链路层都使用确认。)这一层可能会进行错误检测、重传以及流量控制。一些数据链路层还可能会将大的网络包分割成多个帧并在接收者端对这些帧进行重组。
从应用程序编程的角度来讲通常可以忽略数据链路层,因为所有的通信细节都是由驱动和硬件来处理的。
对于有关IP的讨论来讲,数据链路层中比较重要的一个特点是最大传输单元(MTU)。数据链路层的MTU是该层所能传输的帧大小的上限。不同的数据链路层的MTU是不同的。
命令netstat –i会列出系统中的网络接口,包括其MTU。
网络层:IP
位于数据链路层之上的是网络层,它关注的是如何将包(数据)从源主机发送到目标主机。这一层执行了很多任务,包括以下几个。
- 将数据分解成足够小的片段以便数据链路层进行传输(如有必要的话)。
- 在因特网上路由数据。
- 为传输层提供服务。
在TCP/IP协议套件中,网络层的主要协议是IP。在4.2BSD实现中出现的IP的版本是IP版本4(IPv4)。在20世纪90年代早期设计出了IP的一个修正版:IP版本6(IPv6)。这两个版本之间最显著的差别在于IPv4使用32位地址来标识子网和主机,而IPv6则使用了128位的地址,从而能为主机提供更大的地址范围。虽然目前在因特网上IPv4仍然是使用最广的IP版本,但在将来它会被IPv6所取代。IPv4和IPv6都支持高层的UDP和TCP传输层协议(以及很多其他协议)。
尽管从理论上来讲,32位的地址空间提供了数以亿计的IPv4网络地址,但地址的结构和分配放置决定了实际可用的地址数量要少许多。IPv4地址空间的枯竭是创造IPv6主要原因。
有关IPv6的简史可在https://www.laynetworks.com/IPv6.htm 处找到。
IPv4和IPv6的存在引出了一个问题“IPv5呢?”事实上从来就没有IPv5这种东西。每个IP数据报头都包含一个4位的版本号字段(即IPv4数据报的这个字段值总是数字4),而版本号5则被指派给了一个试验协议因特网流协议Internet Stream Protocol。(RFC 1819描述了这个协议的第二版,简写为ST-II。)在20世纪70年代最初构想的时候,这个面向连接的协议就被设计成支持音频和视频传输以及分布式仿真。由于IP数据报版本号5已经被指派过了,因此IPv4的升级版就使用了版本号6。
图58-2给出了一个裸socket(SOCK_RAW),它允许应用程序直接与IP层进行通信。这里不会对裸socket的使用进行描述,因为大多数应用程序会使用基于其中一种传输层协议(TCP或UDP)之上的socket。[Stevens et al., 2004]的第28章对裸socket进行了描述。有关裸socket的使用方面的一个富有教育意义的例子是sendip程序(https://www.earth.li/projectpurple/progs/sendip.html ),它是一个命令行驱动的工具,允许使用任意内容来构建和传输IP数据报(包括构建UDP数据报和TCP段的选项)。
IP传输数据报
IP以数据报(包)的形式来传输数据。在两个主机之间发送的每一个数据报都是在网络上独立传输的,它们经过的路径可能会不同。一个IP数据报包含一个头,其大小范围为20字节到60字节。这个头中包含了目标主机的地址,这样就可以在网络上将这个数据报路由到目标地址了。此外,它还包含了包的源地址,这样接收主机就知道数据报的源头。
发送主机可以伪造一个包的源地址,这也是SYN洪泛这种TCP拒绝服务攻击的基础。[Lemon, 2002]描述了这种攻击的细节以及现代TCP实现为解决这个问题所采取的措施。
一个IP实现可能会给它所支持的数据报的大小设定一个上限。所有IP实现都必须做到数据报的大小上限至少与规定的IP最小重组缓冲区大小(minimum reassembly buffer size)一样大。在IPv4中,这个限制值是576字节;在IPv6中,这个限制值是1500字节。
IP是无连接和不可靠的
IP是一种无连接协议,因为它并没有在相互连接的两个主机之间提供一个虚拟电路。IP也是一种不可靠的协议:它尽最大可能将数据报从发送者传输给接收者,但并不保证包到达的顺序会与它们被传输的顺序一致,也不保证包是否重复,甚至都不保证包是否会达到接收者。IP也没有提供错误恢复(头信息错误的包会被静默地丢弃)。可靠性是通过使用一个可靠的传输层协议(如TCP)或应用程序本身来保证的。
IPv4为IP头提供了一个校验和,这样就能够检测出头中的错误,但并没有为包中所传输的数据提供任何错误检测机制。IPv6并没有为IP头提供检验和,它依赖高层协议来完成错误检测和可靠性。(UDP校验和在IPv4是可选的,但一般来讲都是启用的;UDP校验和在IPv6是强制的。TCP校验和在IPv4和IPv6中都是强制的。)
IP数据报的重复是可能发生的,因为一些数据链路层采用了一些技术来确保可靠性以及IP数据报可能会以隧道形式穿越一些采用了重传机制的非TCP/IP网络。
IP可能会对数据报进行分段
IPv4数据报的最大大小为65 535字节。在默认情况下,IPv6允许一个数据报的最大大小为65 575字节(40字节用于存放头信息,65 535字节用于存放数据),并且为更大的数据报(所谓的jumbograms)提供了一个选项。
之前曾经提过大多数数据链路层会为数据帧的大小设定一个上限(MTU)。如在常见的以太网架构中这个上限值是1500字节(比一个IP数据报的最大大小要小得多)。IP还定义了路径MTU的概念,它是源主机到目的主机之间路由上的所有数据链路层的最小MTU。(在实践中,以太网MTU通常是路径中最小的MTU。)
当一个IP数据报的大小大于MTU时,IP会将数据报分段(分解)成一个个大小适合在网络上传输的单元。这些分段在达到最终目的地之后会被重组成原始的数据报。(每个IP分段本身就是包含了一个偏移量字段的IP数据报,该字段给出了一个该分段在原始数据报中的位置。)
IP分段的发生对于高层协议层是透明的,并且一般来讲也并不希望发生这种事情([Kent & Mogul, 1987])。这里的问题在于由于IP并不进行重传并且只有在所有分段都达到目的地之后才能对数据报进行组装,因此如果其中一些分段丢失或包含传输错误的话就会导致整个数据报不可用。在一些情况下,这会导致极高的数据丢失率(适用于不进行重传的高层协议,如UDP)或降低传输速率(适用于进行重传的高层协议,如TCP)。现代TCP实现采用了一些算法(路径MTU发现)来确定主机之间的一条路径的MTU,并根据该值对传递给IP的数据进行分解,这样IP就不会碰到需要传输大小超过MTU的数据报的情况了。UDP并没有提供这种机制。
IP地址
一个IP地址包含两个部分:一个是网络ID,它指定了主机所属的网络;另一个是主机ID,它标识出了位于该网络中的主机。
IPv4地址
一个IPv4地址包含32位。当以人类可读的形式来表示时,这些地址通常的书写通常采用点分十进制标记法,即将地址的4个字节写成一个十进制数字,中间以点号隔开,如204.152.189.116。
当一个组织为其主机申请一组IPv4地址时,它会收到一个32位的网络地址以及一个对应的32位的网络掩码。在二进制形式中,这个掩码最左边的位由1构成,掩码中剩余的位用0填充。这些1表示地址中哪些部分包含了所分配到的网络ID,而这些0则表示地址中哪些部分可供组织用来为网络中的主机分配唯一的ID。掩码中网络ID部分的大小会在分配地址时确定。由于网络ID部分总是占据着掩码最左边的部分,因此可以通过下面的标记法来指定分配的地址范围。
204.152.189.0/24
这里的/24表示分配的地址的网络ID由最左边的24位构成,剩余的8位用于指定主机ID。或者在这种情况下也可以说网络掩码的点分十进制标记是255.255.255.0。
拥有这个地址的组织可以将254个唯一的因特网地址分配给其计算机——204.152.189.1到204.152.189.254。有两个地址是无法分配给计算机的,其中一个地址的主机ID的位都是0,它用来标识网络本身,另一个地址的主机ID的位都是1——在本例中是204.152.189.255——它是子网广播地址。
一些IPv4地址拥有特殊的含义。特殊地址127.0.0.1一般被定义为回环地址,它通常会被分配给主机名localhost。(网络127.0.0.0/8中的所有地址都可以被指定为IPv4回环地址,但通常会选择127.0.0.1。)发送到这个地址的数据报实际上不会到达网络,它会自动回环变成发送主机的输入。使用这个地址可以便捷地在同一主机上测试客户端和服务器程序。在C程序中定义了整数常量INADDR_LOOPBACK来表示这个程序。
常量INADDR_ANY就是所谓的IPv4通配地址。通配IP地址对于将Internet domain socket绑定到多宿主机上的应用程序来讲是比较有用的。如果位于一台多宿主机上的应用程序只将socket绑定到其中一个主机IP地址上,那么该socket就只能接收发送到该IP地址上的UDP数据报和TCP连接请求。但一般来讲都希望位于一台多宿主机上的应用程序能够接收指定任意一个主机IP地址的数据报和连接请求,而将socket绑定到通配IP地址上使之成为了可能。SUSv3并没有为INADDR_ANY规定一个特定的值,但大多数实现将其定义成了0.0.0.0(全是0)。
一般来讲,IPv4地址是划分子网的。划分子网将一个IPv4地址的主机ID部分分成两个部分:一个子网ID和一个主机ID。(如何划分主机ID的位完全是由网络管理员来决定的。)子网划分的原理在于一个组织通常不会将其所有主机接到单个网络中。相反,组织可能会开启一组子网(一个“内部互联网络”),每个子网使用网络ID和子网ID组合起来标识。这种组合通常被称为扩展网络ID。在一个子网中,子网掩码所扮演的角色与之前描述的网络掩码的角色是一样的,并且可以使用类似的标记法来表示分配给一个特定子网的地址范围。
例如假设分配到的网络ID是204.152.189.0/24,这样可以通过将主机ID的8位中的4位划分成子网ID并将剩余的4位划分成主机ID来对这个地址范围划分子网。在这种情况下,子网掩码将由28个前导1后面跟着4个0构成,ID为1的子网将会被表示为204.152.189.16/28。
IPv6地址
IPv6地址的原理与IPv4地址是类似的,它们之间关键的差别在于IPv6地址由128位构成,其中地址中的前面一些位是一个格式前缀,表示地址类型。(这里不会深入介绍这些地址类型的细节,细节信息可参考[Stevens et al., 2004]的附录A和RFC 3513。)
IPv6地址通常被书写成一系列用冒号隔开的16位的十六进制数字,如下所示。
F000:0:0:0:0:0:A:1
IPv6地址通常包含一个0序列,并且为了标记方便,可以使用两个分号(::)来表示这种序列。因此上面的地址可以被重写成:
F000:A:1
在IPv6地址中只能出现一个双冒号标记,出现多次的话会造成混淆。
IPv6也像IPv4地址那样提供了环回地址(127个0后面跟着一个1,即::1)和通配地址(所有都为0,可以书写成0::0或::)。
为允许IPv6应用程序与只支持IPv4的主机进行通信,IPv6提供了所谓的IPv4映射的IPv6地址。
在书写IPv4映射的IPv6地址时,地址的IPv4部分(即最后4个字节)会被书写成IPv4的点分十进制标记。因此与204.152.189.116等价的IPv4映射的IPv6地址是::FFFF:204.152.189.116。
传输层
在TCP/IP套件中使用广泛的两个传输层协议如下。
- 用户数据报协议(UDP)是数据报socket所使用的协议。
- 传输控制协议(TCP)是流socket所使用的协议。
端口号
传输层协议的任务是向位于不同主机(或有时候位于同一主机)上的应用程序提供端到端的通信服务。为完成这个任务,传输层需要采用一种方法来区分一个主机上的应用程序。在TCP和UDP中,这种区分工作是通过一个16位的端口号来完成的。
众所周知的、注册的以及特权端口
有些众所周知的端口号已经被永久地分配给特定的应用程序了(也称为服务)。例如ssh(安全的shell)daemon使用众所周知的端口22,HTTP(Web服务器和浏览器之间通信时所采用的协议)使用众所周知的端口80。众所周知的端口的端口号位于0~1023之间,它是由中央授权机构互联网号码分配局(IANA, https://www.iana.org/ )来分配的。一个众所周知的端口号的分配是由一个被核准的网络规范(通常以RFC的形式)来规定的。
IANA还记录着注册端口,将这些端口分配给应用程序开发人员的过程就不那么严格了(这也意味着一个实现无需保证这些端口是否真正用于它们注册时申请的用途)。IANA注册的端口范围为1024~41951。(不是所有位于这个范围内的端口都被注册了。)
IANA众所周知的更新列表和注册端口分配情况可以在https://www.iana.org/assignments/port-numbers 上找到。
在大多数TCP/IP实现(包括Linux)中,范围在0到1023间的端口号也是特权端口,这意味着只有特权(CAP_NET_BIND_SERVICE)进程可以绑定到这些端口上,从而防止了普通用户通过实现恶意程序(如伪造ssh)来获取密码。(有些时候,特权端口也被称为保留端口。)
尽管端口号相同的TCP和UDP端口是不同的实体,但同一个众所周知的端口号通常会同时被分配给基于TCP和UDP的服务,即使该服务通常只提供了其中一种协议服务。这种惯例避免了端口号在两个协议中产生混淆的情况。
临时端口
如果一个应用程序没有选择一个特定的端口(即在socket术语中,它没有调用bind()将其socket绑定到一个特定的端口上),那么TCP和UDP会为该socket分配一个唯一的临时端口(即存活时间较短)。在这种情况下,应用程序——通常是一个客户端——并不关心它所使用的端口号,但分配一个端口对于传输层协议标识通信端点来讲是有必要的。这种做法的另一个结果是位于通信信道另一端的对等应用程序就知道如何与这个应用程序通信了。TCP和UDP在将socket绑定到端口0上时也会分配一个临时端口号。
IANA将位于49152到65535之间的端口称为动态或私有端口,这表示这些端口可供本地应用程序使用或作为临时端口分配。然后不同的实现可能会在不同的范围内分配临时端口。在Linux上,这个范围是由包含在文件/proc/sys/net/ipv4/ip_local_port_range中的两个数字来定义的(可通过修改这两个数字来修改范围)。
用户数据报协议(UDP)
UDP仅仅在IP之上添加了两个特性:端口号和一个进行检测传输数据错误的数据校验和。
与IP一样,UDP也是无连接的。由于它并没有在IP之上增加可靠性,因此UDP是不可靠的。如果一个基于UDP的应用程序需要确保可靠性,那么这项功能就必须要在应用程序中予以实现。如果剔除不可靠这个特点的话,在有些时候可能倾向于使用UDP而不是TCP。
UDP和TCP使用的校验和的长度只有16位并且只是简单的“总结性”校验和,因此无法检测出特定的错误,其结果是无法提供较强的错误检测机制。繁忙的互联网服务器通常只能每隔几天看一下未检测出的传输错误的平均情况([Stone & Partridge, 2000])。需要更多确保数据完整性的应用程序可以使用安全Sockets层(Secure Sockets Layer,SSL),它不仅仅提供了安全的通信,而且还提供更加严格的错误检测过程。或者应用程序也可以实现自己的错误控制机制。
选择一个UDP数据报大小以避免IP分段
IP分段机制并指出过通常应该尽可能地避免IP分段。TCP提供了避免IP分段的机制,但UDP并没有提供相应的机制。使用UDP时如果传输的数据报的大小超过了本地数据链接的MTU,那么很容易就会导致IP分段。
基于UDP的应用程序通常不会知道源主机和目的主机之间的路径的MTU。一般来讲,基于UDP的应用程序会采用保守的方法来避免IP分段,即确保传输的IP数据报的大小小于IPv4的组装缓冲区大小的最小值576字节。(这个值很有可能是小于路径MTU的。)在这576字节中,有8个字节是用于存放UDP头的,另外最少需要使用20个字节来存放IP头,剩下的548字节用于存放UDP数据报本身。在实践中,很多基于UDP的应用程序会选择使用一个更小的值512字节来存放数据报([Stevens, 1994])。
传输控制协议(TCP)
TCP在两个端点(即应用程序)之间提供了可靠的、面向连接的、双向字节流通信信道。为提供这些特性,TCP必须要执行本节中描述的任务。(有关所有这些特性的详细描述可以在[Stevens, 1994]中找到。)
这里使用术语TCP端点来表示TCP连接一端的内核所维护的信息。(通常会进一步对这个术语进行缩写,如仅书写“一个TCP”来表示“一个TCP端点”或“客户端TCP”来表示“客户端应用程序维护的TCP端点。”)这部分信息包括连接这一端的发送和接收缓冲区以及维护的用来同步两个已连接的端点的操作的状态信息。在本书余下的部分中将使用术语接收TCP和发送TCP来表示一个用来在特定方向上传输数据的流socket连接两端的接收和发送应用程序。
连接建立
在开始通信之前,TCP需要在两个端点之间建立一个通信信道。在连接建立期间,发送者和接收者需要交换选项来协商通信的参数。
将数据打包成段
数据会被分解成段,每一个段都包含一个校验和,从而能够检测出端到端的传输错误。每一个段使用单个IP数据报来传输。
确认、重传以及超时
当一个TCP段无错地达到目的地时,接收TCP会向发送者发送一个确认,通知它数据发送递送成功了。如果一个段在到达时是存在错误的,那么这个段就会被丢弃,确认信息也不会被发送。为处理段永远不到达或被丢弃的情况,发送者在发送每一个段时会开启一个定时器。如果在定时器超时之前没有收到确认,那么就会重传这个段。
由于所使用的网络以及当前的流量负载会影响传输一个段和接收其确认所需的时间,因此TCP采用了一个算法来动态地调整重传超时时间(RTO)的大小。
接收TCP可能不会立即发送确认,而是会等待几毫秒来观察一下是否可以将确认塞进接收者返回给发送者的响应中。(每个TCP段都包含一个确认字段,这样就能将确认塞进TCP段中了。)这项被称为延迟ACK的技术的目的是能少发送一个TCP段,从而降低网络中包的数量以及降低发送和接收主机的负载。
排序
在TCP连接上传输的每一个字节都会分配到一个逻辑序号。这个数字指出了该字节在这个连接的数据流中所处的位置。(这个连接中的两个流各自都有自己的序号计数系统。)当传输一个TCP分段时会在其中一个字段中包含这个段的第一个字节的序号。
在每一个段中加上一个序号有几个作用。
- 这个序号使得TCP分段能够以正确的顺序在目的地进行组装,然后以字节流的形式传递给应用层。(在任意一个时刻,在发送者和接收者之间可能存在多个正在传输的TCP分段,这些分段的到达顺序可能与被发送的顺序可能是不同的。)
- 由接收者返回给发送者的确认消息可以使用序号来标识出收到了哪个TCP分段。
- 接收者可以使用序号来移除重复的分段。发生重复的原因可能是因为IP数据段重复,也可能是因为TCP自己的重传算法会在一个段的确认丢失或没有按时收到时重传一个成功递送出去的段。
一个流的初始序号(ISN)不是从0开始的,相反,它是通过一个算法来生成的,该算法会递增分配给后续TCP连接的ISN(为防止出现前一个连接中的分段与这个连接中的分段混淆的情况)。这个算法也使得猜测ISN变得困难起来。序号是一个32位的值,当到达最大取值时会回到0。
流量控制
流量控制防止一个快速的发送者将一个慢速的接收者压垮。要实现流量控制,接收TCP就必须要为进入的数据维护一个缓冲区。(每个TCP在连接建立阶段会通告其缓冲区的大小。)当从发送TCP端收到数据时会将数据累积在这个缓冲区中,当应用程序读取数据时会从缓冲区中删除数据。在每个确认中,接收者会通知发送者其进入数据缓冲区的可用空间(即发送者可以发送多少字节)。TCP流量控制算法采用了所谓的滑动窗口算法,它允许包含总共N字节(提供的窗口大小)的未确认段同时在发送者和接收者之间传输。如果接收TCP的进入数据缓冲区完全被充满了,那么窗口就会关闭,发送TCP就会停止传输数据。
接收者可以使用SO_RCVBUF socket选项来覆盖进入数据缓冲区的默认大小(参见socket(7)手册)。
拥塞控制:慢启动和拥塞避免算法
TCP的拥塞控制算法被设计用来防止快速的发送者压垮整个网络。如果一个发送TCP发送包的速度要快于一个中间路由器转发的速度,那么该路由器就会开始丢弃包。这将会导致较高的包丢失率,其结果是如果TCP保持以相同的速度发送这些被丢弃的分段的话就会极大地降低性能。TCP的拥塞控制算法在下列两个场景中是比较重要的。
- 在连接建立之后:此时(或当传输在一个已经空闲了一段时间的连接上恢复时),发送者可以立即向网络中注入尽可能多的分段,只要接收者公告的窗口大小允许即可。(事实上,这就是早期的TCP实现的做法。)这里的问题在于如果网络无法处理这种分段洪泛,那么发送者会存在立即压垮整个网络的风险。
- 当拥塞被检测到时:如果发送TCP检测到发生了拥塞,那么它就必须要降低其传输速率。TCP是根据分段丢失来检测是否发生了拥塞,因为传输错误率是非常低的,即如果一个包丢失了,那么就认为发生了拥塞。
TCP的拥塞控制策略组合采用了两种算法:慢启动和拥塞避免。
慢启动算法会使发送TCP在一开始的时候以低速传输分段,但同时允许它以指数级的速度提高其速率,只要这些分段都得到接收TCP的确认。慢启动能够防止一个快速的TCP发送者压垮整个网络。但如果不加限制的话,慢启动在传输速率上的指数级增长意味着发送者在短时间内就会压垮整个网络。TCP的拥塞避免算法用来防止这种情况的发生,它为速率的增长安排了一个管理实体。
有了拥塞避免之后,在连接刚建立时,发送TCP会使用一个较小的拥塞窗口,它会限制所能传输的未确认的数据数量。当发送者从对等TCP处接收到确认时,拥塞窗口在一开始时会呈现指数级增长。但一旦拥塞窗口增长到一个被认为是接近网络传输容量的阈值时,其增长速度就会变成线性,而不是指数级的。(对网络容量的估算是根据检测到拥塞时的传输速率来计算得出的或者在一开始建立连接时设定为一个固定值。)在任何时刻,发送TCP传输的数据数量还会受到接收TCP的通告窗口和本地的TCP发送缓冲器的大小的限制。
慢启动和拥塞避免算法组合起来使得发送者可以快速地将传输速度提升至网络的可用容量,并且不会超出该容量。这些算法的作用是允许数据传输快速地到达一个平衡状态,即发送者传输包的速率与它从接收者处接收确认的速率一致。
SOCKET:Internet Domain
Internet domain流socket是基于TCP之上的,它们提供了可靠的双向字节流通信信道。
Internet domain数据报socket是基于UDP之上的。UDP socket与之在UNIX domain中的对应实体类似,但需要注意下列差别。
- UNIX domain数据报socket是可靠的,但UDP socket则是不可靠的——数据报可能会丢失、重复或到达的顺序与它们被发送的顺序不同。
- 在一个UNIX domain数据报socket上发送数据会在接收socket的数据队列为满时阻塞。与之不同的是,使用UDP时如果进入的数据报会使接收者的队列溢出,那么数据报就会静默地被丢弃。
网络字节序
IP地址和端口号是整数值。在将这些值在网络中传递时碰到的一个问题是不同的硬件结构会以不同的顺序来存储一个多字节整数的字节。从图59-1中可以看出,存储整数时先存储(即在最小内存地址处)最高有效位的被称为大端,那些先存储最低有效位的被称为小端。(这两个术语出自Jonathan Swift在1726年发表的讽刺小说《格列佛游记》,在那篇小说中这两个术语指在另一端打开煮鸡蛋的敌对政治派别。)小端架构中最值
TODO.
SOCKET:例程
简单的服务器
这个服务器所做的全部工作是在流式连接上发送字符串 “Hello, World!\n”。
server.c
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/wait.h>
#define MYPORT 3490 /*定义用户连接端口*/
#define BACKLOG 10 /*多少等待连接控制*/
int main()
{
int sockfd, new_fd; /* listen on sock_fd, new connection on new_fd */
struct sockaddr_in my_addr; /* my address information */
struct sockaddr_in their_addr; /* connector's address information */
int sin_size;
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket");
exit(1);
}
my_addr.sin_family = AF_INET; /* host byte order */
my_addr.sin_port = htons(MYPORT); /* short, network byte order */
my_addr.sin_addr.s_addr = INADDR_ANY; /* auto-fill with my IP */
bzero(&(my_addr.sin_zero),sizeof(my_addr.sin_zero)); /* zero the rest of the struct */
if (bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr))== -1) {
perror("bind");
exit(1);
}
if (listen(sockfd, BACKLOG) == -1) {
perror("listen");
exit(1);
}
while(1) { /* main accept() loop */
sin_size = sizeof(struct sockaddr_in);
if ((new_fd = accept(sockfd, (struct sockaddr *)&their_addr, &sin_size)) == -1) {
perror("accept");
continue;
}
printf("server: got connection from %s\n", \
inet_ntoa(their_addr.sin_addr));
if (!fork()) { /* this is the child process */
if (send(new_fd, "Hello, world!\n", 14, 0) == -1)
{
perror("send");
}
close(new_fd);
exit(0);
}
close(new_fd); /* parent doesn't need this */
while(waitpid(-1,NULL,WNOHANG) > 0); /* clean up child processes */
}
return 0;
}
在一台机器上运行该程序:
gcc server.c
./a.out
然后在另外一机器上登陆:
注意!
如果没有telnet,可以如下命令安装:
sudo apt-get install telnet
telnet 主机名 3490
此时,服务器显示:
server: got connection from 192.168.43.231
客户端显示:
Trying 192.168.43.20...
Connected to deepin.
Escape character is '^]'.
Hello, world!
Connection closed by foreign host.
简单客户端
这个程序的所有工作是通过 3490 端口连接到命令行中指定的主机,然后得到服务器发送的字符串。
client.c
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/wait.h>
#define PORT 3490 /* 客户机连接远程主机的端口 */
#define MAXDATASIZE 100 /* 每次可以接收的最大字节 */
int main(int argc, char *argv[])
{
int sockfd, numbytes;
char buf[MAXDATASIZE];
struct sockaddr_in their_addr; /* connector's address information */
struct hostent *he;
char *ptr;
struct in_addr addr;
if (argc != 2) {
fprintf(stderr,"usage: Input client hostname or ip address\n");
exit(1);
}
ptr = argv[1];
if(ptr[0]=='1')
{
if (inet_pton(AF_INET, ptr, &addr) <= 0) {
printf("inet_pton error:%s\n", strerror(errno));
return -1;
}
else
{;}
he = gethostbyaddr((const char*)&addr, sizeof(addr), AF_INET);
}
else if ((he=gethostbyname(ptr)) == NULL) { /* get the host info */
herror("gethostbyname");
exit(1);
}
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket");
exit(1);
}
their_addr.sin_family = AF_INET; /* host byte order */
their_addr.sin_port = htons(PORT); /* short, network byte order */
their_addr.sin_addr = *((struct in_addr *)he->h_addr_list[0]);
bzero(&(their_addr.sin_zero),sizeof(their_addr.sin_zero)); /* zero the rest of the struct */
if(connect(sockfd,(struct sockaddr *)&their_addr,sizeof(struct sockaddr)) == -1) {
perror("connect");
exit(1);
}
if ((numbytes=recv(sockfd, buf, MAXDATASIZE, 0)) == -1) {
perror("recv");
exit(1);
}
buf[numbytes] = '\0';
printf("Received: %s",buf);
close(sockfd);
return 0;
}
gcc client.c
在A电脑上运行服务器:
./a.out
在B电脑上运行客户端:
./a.out A主机名或ip地址
运行后,A显示:
server: got connection from 192.168...
B显示:
Received: Hello, world!
终端
历史上,用户接入一个UNIX系统都是通过串行线(RS-232连接)连接到一个终端上的。终端由阴极射线管(CRT)组成,能够显示出字符,而且在某些情况下可以显示出基本图形。一般来说,CRT能提供单色24行80列的显示效果。按照当今的标准,这些CRT体积很小且昂贵。甚至在更早的时期,终端有时候还是硬拷贝电传设备。串行线也可以用来连接其他的设备,比如打印机和用来在计算机之间互连的调制解调器。
在早期的UNIX系统上,连接到系统上的终端由字符型设备来表示,名称以/dev/ttyn的形式给出。(在Linux上,/dev/ttyn设备是系统上的虚拟控制台。)我们常会看到tty(源自teletype)作为终端的缩写形式。
尤其是在UNIX的早期时代,终端设备并没有统一的标准。这意味着不同的字符序列需要执行类似移动光标到一行的开头,或者移动光标到屏幕中央这样的操作。(终于有些设备商实现了这样的转义序列——例如,Digitals的VT-100成为了事实上的标准,最终成为了ANSI标准。但是,依然还存在着各种各样的终端类型。)由于缺乏统一的标准,这就意味着很难编写可移植的程序来利用终端的特性。vi编辑器是早期有着这种可移植性需求的例子。termcap和terminfo数据库(在[Strang et al., 1988]中有描述)中的制表操作应该如何针对多种类型的终端执行各式各样的屏幕控制操作呢?curses库([Strang, 1986])正是为了应对这种缺失的标准应运而生。
如今传统型终端已经不常见了。现代UNIX系统的常用接口是高性能位映射图形显示器上的 X Window 窗口管理器。(老式的终端所提供的功能大致上等同于一个单独的终端窗口——xterm终端或其他类似的产品——运行在X Window系统之上。这种终端的用户只有一个单独的面向系统的“窗口”,这一事实是由开发作业控制设施所驱动的。)同样的,如今许多直接连接到计算机上的设备(例如打印机)都是带有网络连接的智能型设备。
以上所述都是在说如今面向终端设备的编程已经不像以前那么频繁了。因此,本章把重点放在终端编程上,尤其是与软件终端模拟器相关的方面(例如xterm及类似的模拟器)。
整体概览
传统型终端和终端模拟器都需要同终端驱动程序相关联,由驱动程序负责处理设备上的输入和输出。
当执行输入时,驱动程序可以工作在以下两种模式下。
- 规范模式:在这种模式下,终端的输入是按行来处理的,而且可进行行编辑操作。每一行都由换行符来结束,当用户按下回车键时可产生换行符。在终端上执行的read()调用只会在一行输入完成之后才会返回,且最多只会返回一行。(如果read()请求的字节数少于当前行中的可用字节,那么剩下的字节在下次 read()调用时可用。)这是默认的输入模式。
- 非规范模式:终端输入不会被装配成行。像 vi、more 和 less 这样的程序会将终端置于非规范模式,这样不需要用户按下回车键它们就能读取到单个的字符了。