unix高级编程

UNIX环境高级编程

第一章 UNIX基础知识

UNIX体系结构

操作系统是一个软件, 控制计算机硬件资源, 提供程序运行环境. 也叫做内核. 内核接口是系统调用. 公共函数库构建在系统之上. 应用程序可以调用系统调用也可以调用公共函数. shell是一个特殊的应用程序, 为运行其他应用程序提供了一个接口.

shell

shell是一个命令行解释器, 读取用户输入, 然后执行命令. shell的输入通常来自终端(交互式shell), 有时来源于文件(shell脚本).

文件和目录

目录是一个包含目录项的文件.

创建新目录是会自动创建两个文件名: .(点) ..(点点) 点指向当前目录, 点点指向父目录.

ex: 将工作空间转到父目录的父目录.

1
cd ../..

工作目录

每个进程都有一个工作目录, 称为当前工作目录. 所有相对路径都是从当前目录开始解释. 可以通过chdir函数更改工作目录.

输入和输出

文件描述符

文件描述符通常是一个小的非负整数, 内核用其来标识特定进程正在访问的文件. 当内核打开或者创建文件时都会返回一个文件描述符. 读写文件时使用文件描述符.

标准输入, 标准输出和标准错误

当程序执行时, 所有shell都会打开三个文件描述符, 即标准输入, 标准输出和标准错误. 默认情况下三个描述符都指向终端(即输入输出和错误都通过终端进行交互). 同时可以将一个或者3个描述符重定向到指定文件. “> file_name”: 将标准输出重定向到file_name文件中(如果没有就会创建). “<file_name”: 将标准输入重定向到file_name中.

ex:

1
ls > files_list -a

将当前目录下的文件输出到files_list文件中.

对于一个可执行文件a.out

1
./a.out < input_file > output_file

此时程序中标准输入就会从input_file读取, 标准输出就会到output_file中.

不带缓冲的I/O

函数open, read, write, lseek以及close提供了不带缓冲的I/O. 均使用文件描述符.

1
2
read(文件描述符, char [], BUFFSIZE); //从文件描述符连接的文件读字符串
write(文件描述符, char [], BUFFSIZE); //向文件描述符连接的文件写字符串

程序与进程

程序:

存储在磁盘的可执行文件, 内核使用exec()(7个), 将程序读入内存并执行.

进程与进程ID

进程: 程序的执行实例. UNIX保证每个进程都有唯一的一个数字标识符. 即进程ID. 通过getpid()函数获取进程ID.

进程管理

三个主要函数: fork, exec和waitpid函数.

fork

创建一个新的进程, 返回两个pid_t, 对父进程返回子进程的ID号, 对子进程返回0. 当调用此函数时, 新进程调用父进程的一个副本. 相当于将父进程进行了一份拷贝(?), 在调用该命令之前的信息两个进程完全一致(并不是资源共享, 只是资源的值一致, 对于变量来说, 指向地址不同,而是值相同). 该命令之后的两个进程分别执行. 对于返回的两个不同的值作为用来作为后续执行代码的选择区分.

waitpid

等待进程执行结束. 参数为进程ID, 返回进程的终止状态.

ex: simple_shell.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include"apue.h"
#include<sys/wait.h>

int main(void)
{
char buf[MAXLINE];
pid_t pid;
int statue;
printf("%%");
while(fgets(buf, MAXLINE, stdin) != NULL) //每次读取标准输入的一行, 以空(ctrl+D)作为结尾
{
if(buf[strlen(buf)-1] == '\n') //替换换行符为空字符
buf[strlen(buf)-1] = 0;
if((pid = fork())<0) // 创建新进程, if后的程序会在父进程与新进程中执行两遍, 按照pid选择执行方式.
err_sys("fork error");
else
{
if(pid == 0) //执行子进程
{
execlp(buf, buf, (char*)0);
err_sys("coundn't execute: %s", buf);
exit(127); // 结束子进程
}
}
if((pid = waitpid(pid, &statue, 0))<0) // 由于子进程在之前已经结束, 子进程无法执行到此, 所以pid只能是子进程ID(父进程返回为0), 该语句为等待子进程结束,statue用来返回子进程终止状态。
err_sys("waitpid error");
printf("%%");
}
exit(0);
}

线程和线程ID

线程: 某一时刻执行的一组机器指令. 一个进程内所有线程共享同一地址空间, 文件描述符, 栈以及与进程相关的属性. 线程也有线程标识, 线程ID只能所属进程中有效.

出错处理

当UNIX出错时, 通常会返回一个负值, 部分整型变量erron通常被设置为具有特定含义的值. 中定义了erron以及可以赋予它的常量.

c标准库定义了两个函数用于打印出错信息.

strerror()

1
2
#include<string.h>
void char* strerror(int errnum); //根据输入的整数(代表错误类型)获得错误信息.

perror()

1
2
#inclide<stdio.h>
void perror(const char *mag) ; //向标准输出中打印mag信息: 错误信息.错误信息由erron指明.

ex:

1
2
3
4
5
6
7
8
#includ"apue.h"
#include<stdio.h>
int main(argc, *argv[])
{
fprintf(stderr, "EACCES: %s \n", strerror(EACCES)); // EACCES为头文件中包含指明错误的常量.
erron = ENOENT; // erron同样是被定义在头文件中.用来指明perror打印的错误类型.
perror(argv[0]);
}

用户ID

用户ID

口令文件登录项中用户ID是一个数值, 标识不同用户. ID= 0 为超级用户. 如果进程具有超级用户权限则大多数权限检测都不用进行.

组ID

/etc/group文件中.

附属ID

信号(signal)

信号用于通知进程发生了某种情况. 进程有三种处理信号的方式:

  1. 忽略信号
  2. 按默认方式处理.
  3. 通过一个函数, 当信号发生时调用该函数, 称为捕获信号.

时间值

日历时间

从1970年1月1日00:00:00到指定时间进过多少秒.系统基本数据类型time_t存储这种时间值.

进程时间

用于度量进程使用CPU资源. 进程时间以时钟滴答计算. 每秒可以有不同时间滴答数取值.

度量一个进程执行的时间时 UNIX系统为一个进程维护3个进程时间值.

  1. 时钟时间(CPU时间): 进程运行总时间.
  2. 用户CPU时间: 执行用户指令所用的时间量.
  3. 系统CPU时间: 进程中调用内核程序时所使用的时间.

用户CPU时间和系统CPU时间之和被称为CPU时间.

获得进程时间方式: 在执行程序的指令前加上time即可.

ex: time ls -a;

系统调用和库函数

操作系统提供的服务的入口点被称为系统调用.

UNIX使用技术为为每个系统调用都在标准C库中设置一个具有同样名字的函数. 用户进程用标准C调用序列来调用这些函数, 这些函数又用系统所需要的技术调用相应的内核服务.

第三章 文件I/O

文件描述符

对于内核而言, 所有打开的文件都是通过文件描述符引用. 文件描述符为非负整数, 打开文件或者创建一个新文件时将返回一个文件描述符. UNIX系统shell将文件描述符0与标准输入关联(STDIN_FILENO). 1与标准输出关联(STDOUT_FILENO), 2与标准错误关联(STDERR_FILENO).

函数open和openat

open和openat用于打开或者创建一个文件. 原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include<fcntl.h>
int open(const char *path, int oflag, .../*mode_t mode */);
int openat(int fd, const char *path, int oflag, .../*mode_t mode */);
...表示最后一个参数, 表明余下的参数数量和类型均不定. 对与open只有在创建文件时才会使用到最后的参数;
path表示要打开或者创建的文件名. oflag表示此函数的多个选项, 使用下列一个或对个常量进行或运算构成oflag参数, 均被定义在<fcntl.h>头文件中:
oflag参数:
// 下面五个只能选一个
O_RDONLY read only;
O_WRONLY write only;
O_RDWR write and read;
O_EXEC only execute;
O_SEARCH only search; // linux don't exist.
// 下面的可以任性选择
O_APPEND 每次写时追加到末尾
O_CLOEXEC 把FD_CLOEXEC常量设置为文件描述标识符
O_CREAT 若此文件不存在则创建, 创建时将使用最后的参数指明新文件的访问权限
O_DIRECTORY 如果path引用得到不是目录就会出错
O_EXCL 如果同时指定了O_CREAT而文件已经存在则会报错, 用于测试文件是否已经存在. 使得测试和创建成为一个原子操作
O_NOCTTY 如果path引用的是终端设备, 则不将该设备分配作为此进程的控制终端
O_NOFOLLOW 如果path引用的是一个符号连接将会出错
O_NONBLOCK 如果path引用的是一个FIFO, 一个块特殊文件或者一个字符特殊文件, 则此选项为文本的本次打开和后续I/O为非阻塞式.
O_SYNC 使每次write等待物理I/O操作完成, 包括由该write引起的文件属性更新.
O_TRUNC 如果此文件存在, 且为写或读写方式打开,则将长度截断为0
O_TTY_INIT 如果打开一个未打开的终端设备, 设置非标准termios参数值, 使其符合Single UNIX Specificatation
O_DSYNC 使每次write要等待物理I/O操作完成, 但是如果改写操作并不影响读刚写入的数据,则不需要等待文件属性被更改.
O_RSYNC 使每一个以文件描述符作为参数进行的read操作等待, 直到所有对文件同一部分挂起的写操作完成.

fd参数将open与openat函数区分开, 主要有三种情况:

  1. path为绝对路径, fd被忽略, open与openat一致.
  2. path指定相对路径, fd指出相对路径名在文件系统中的开始地址. fd参数通过打开相对路径名所在的目录名来获取.
  3. path参数指定了相对路径名, fd参数具有特殊值AT_FDCWD, 在这种情况下, 路径名在当前工作目录中获取, openat函数在操作上与open类似.

函数creat

creat创建一个新文件, 原型:

1
2
3
#include<fcntl.h>
int creat(const char *path, mode_t mode);
// 成功返回只写打开的文件描述符, 错误返回-1;

函数close

close关闭一个打开的文件. 原型:

1
2
#include<fcntl.h>
int close(int fd);

关闭文件还会释放加在该文件上的记录锁.

函数lseek

每一个打开的文件都有一个与其相关联的”当前字节偏移量”. 通常是一个非负整数, 用以度量从文件开始处计算的字节数. 读写操作都是从当前文件偏移量处开始. 当打开一个文件时, 除显示使用O_APPEND选项, 否则偏移量为零. 可以通过lseek设置偏移量. 原型:

1
2
3
4
5
6
7
#include<unistd.h>
off_t lseek(int fd, off_t offset, int whence);
// 成功返回新的文件偏移量, 否则返回-1;
参数offset与whence有关:
1 当whence是SEEK_SET, 则偏移量设置为从文件开始到offset个字节处.
2 当whence是SEEK_CUR, 偏移量为当前值加上offset当前位置.
3 当whence是SEEK_END, 则偏移量设置为文件长度加上offset.

文件偏移量可以大于文件的当前长度, 此时对该文件的下一次写操作将加长该文件, 并在该文件中构成一个空洞, 位于文件中但没有写过的字节都被度为0, 空洞并不占用磁盘空间.

函数read

read从打开文件读取数据, 原型:

1
2
3
#include<unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
//返回类型, 读到的字节数, 若已经达到文件末尾则返回0; 若出错返回-1;

函数write

write函数向一个打开的文件写数据, 原型:

1
2
#include<unistd.h>
ssize_t write(int fd, const void *buf, size_t nbytes);

文件共享

UNIX系统支持不同进程共享打开的文件. 内核使用三种结构表示打开的文件, 他们的关系决定了在文件共享方面一个进程对另一个进程可能产生的影响.

(1) 每个进程在进程表中都有一个记录项, 记录项中包含一张打开文件描述符表, 与文件表项向关联的是:

  1. 文件描述符标志
  2. 指向一个文件表项的指针.

(2) 内核为所有打开文件维持一张文件表. 每个文件表包含:

  1. 文件状态标志(读, 写, 添加, 同步, 阻塞等)
  2. 当前文件偏移量
  3. 指向该文件v节点表项的指针

(3) 每个打开文件(或设备)都有一个v节点结构. v节点包含了文件类型和对此文件进行各种操作函数的指针. 大多数文件, v节点还包含该文件i节点的指针.

下图展示了三者之间的关系:

结构

如果两个独立进程各自打开同一文件, 则有下图关系:

打开同一文件

注意:可能有多个文件描述符项指向同一文件表项.这会在dup或fork函数后产生. 注意: 文件描述符标志与文件状态标志在作用范围上的区别: 前者只用于一个进程的一个描述符, 而后者则应用与指向该文件表项的任意进程中的所有描述符.(有点类型指针和底层存储的区别)

原子操作

原子操作指的是由多步组成的一个操作. 如果该操作原子的执行, 则要么执行完所有的步骤, 要么一步也不执行, 不可能只执行所以步骤中的一个子集. 任何要求多于一个函数调用的操作都不是原子操作, 因为在两个函数调用之间, 内核有可能会临时挂起进程.

函数dup和dup2

dup和dup2都用来赋值一个现有的文件描述符. 原型:

1
2
3
4
#include<unistd.h>
int dup(int fd);
int dup2(int fd, int fd2);
//函数返回值, 若成功返回新的文件描述符, 否则返回-1

由dup返回的新文件描述符一定是当前可用文件描述符中最小数值. dup2可以通过fd2指定新的文件描述符的值. 如果fd2已经打开则先关闭. 如果fd2与fd一致,则不关闭直接返回fd2. 执行dup函数后可能的结果如图:

dup

函数sync, fsync, fdatasync

传统的UNIX系统实现内核中设有缓冲区高速缓存或页高速缓存. 向文件写入数据时, 内核首先将数据复制到缓冲区, 然后排入队列. 当内核需要重用缓冲区来存放其他磁盘块数据时, 它会把延迟写数据块写入磁盘, 为了保证磁盘上实际文件与缓冲区内容一致, 可以使用sync, fsync, fdatasync函数. 原型:

1
2
3
4
#include<unistd.h>
int fsync(int fd);
int fdatasync(int fd);
void sync(void);

sync将所有修改过的缓冲区排入写队列, 然后返回, 并不等待实际写磁盘结束.

fsync函数只对指定文件描述符起作用, 并且等待写磁盘结束才返回.

fdatasync与fsync类似, 不过只影响文件的数据部分, 而fsync除了数据部分还会同步更新文件属性.

函数fcntl

fcntl函数可以改变已经打开文件的属性. 原型:

1
2
#include<fcntl.h>
int fcntl(int fd, int cmd, .../* int arg */);

fcntl函数有以下5中功能:

  1. 赋值一个已有的文件描述符(cmd = F_DUPFD或F_DUPFD_CLOEXEC).
  2. 获取/设置文件描述符标志(cmd = F_GETFD或F_SETFD).
  3. 获取/设置文件状态标志(cmd = F_GETFL或F_SETFL).
  4. 获取/设置异步I/O所有权(cmd = F_GETOWN或F_SETOWN).
  5. 获取/设置记录锁(cmd = F_GETLK, F_SETLK或F_SETLKW).

下面对上述参数进行解释:

F_DUPFD 复制文件描述符fd. 新文件描述符作为返回值. 新描述符与fd共享同一文件表, 但新文件描述符有自己的一套文件描述符标志,其中FD_CLOEXEC文件描述标志被清除(表示该描述符在exec时任有效)
F_DUPFD_CLOEXEC 复制文件描述符, 设置与新描述符关联的FD_CLOEXEC文件描述符标志的值
F_GETFD 对应于fd的文件描述符标志作为函数返回值. 当前只定义了一个文件描述符标志FD_CLOEXEC. 由于五个基本的访问方式标志不是各占一位, 因此我们需要使用屏蔽字O_ACCMODE取得访问标志位, 然后将结果与五个值对比.
F_SETFD 对于fd设置文件描述符标志, 新标志值按第三个参数设置
F_GETFL 对应fd的文件状态标志作为函数返回值
F_SETFL 将文件状态标志设为第三个参数的值
F_GETOWN 获取当前接收SIGIO和SIGURG信号进程ID或进程组ID
F_SETOWN 设置接收SIGIO和SIGURG信号的进程ID或进程组ID

fcntl返回值与命令有关, 如果出错则都返回-1, 否则返回某个其它值.

下表列出了文件状态标志(与open时描述的一样):

fd

例: 查看文件状态标志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include"apue.h"
#include<fcntl.h>
int main(int argc,char *argv[])
{
int val;
if(argc!=2)
err_quit("usage: a.out<descriptor#>");
if((val = fcntl(atoi(argv[1]), F_GETFL, 0))<0)
err_sys("fcntl error for fd %d", atoi(argv[1]));
switch(val & O_ACCMODE)
{
case O_RDONLY:
printf("read only");
break;
case O_WRONLY:
printf("write only");
break;
case O_RDWR:
printf("read write");
break;
default:
err_dump("unknow access mode");
}
if(val & O_APPEND)
printf(", append");
if(val & O_NONBLOCK)
printf(", nonblock");
if(val & O_SYNC)
printf(", sunchronous write");
# if !defined(_POSIX_C_SOURCE) && defined(O_FSYNC) && (O_FSYNC != O_SYNC)
if(val & O_FSYNC)
printf(", synchronous write");
#endif
putchar('\n');
exit(0);
}

//output:
chst@wyk-GL63:~/study_file/unix编程$ ./getfl.o 0 < /dev/tty
read only
chst@wyk-GL63:~/study_file/unix编程$ ./getfl.o 1 > temp.foo
chst@wyk-GL63:~/study_file/unix编程$ cat temp.foo
write only
chst@wyk-GL63:~/study_file/unix编程$ ./getfl.o 2 2>>temp.foo
write only, append
chst@wyk-GL63:~/study_file/unix编程$ ./getfl.o 5 5<>temp.foo
read write

子句5<>temp.foo表示在文件描述符5上打开文件temp.foo以供读写。

函数ioctl

函数ioctl是I/O操作的杂物箱:

1
2
3
4
5
#include<unistd.h> /* system v */
#include<sys/icotl.h> /* BSD and linux */

int ioctl(int fd, int request, ...);
//出错返回-1, 成功返回其他值

下表总结FreeBSD支持的通用ioctl命令的一些类别:

icotl

/dev/fd

较新的系统提供/dev/fd目录, 其目录项是名0, 1, 2等的文件. 打开文件/dev/fd/n等效于复制描述符n.

例:

1
2
3
fd = open("/dev/fd/o", mode);
==
fd = dup(0);

/dev/fd文件主要用于shell, 它允许使用路径名作为调用参数的程序. 例如cat将’-‘解释为标准输入.

1
2
3
4
filter file2 | cat file1 - file3 | lpr
==
filer file2 | cat file1 /dev/fd/0 file3 | lpr
// |表示通道, 即前一个命令的输出作为下一个命令的输入

这里’-‘别替换为filter file2的输出。

第四章 文件和目录

函数stat, fstat, fstatat和lstat

函数原型:

1
2
3
4
5
#include<sys/stat.h>
int stat(const char *restrict pathname, struct stat *restrict buf);
int fstat(int fd, struct stat *buf);
int lstat(const char *restrict pathname, struct stat *restrict buf);
int fstatat(int fd, const char *restrict pathname, struct stat *restrict buf, int flag);

  一旦给出pathname,stat函数返回与此命名文件有关的信息结构。fstat获得已在描述符fd中打开的文件有关信息。lstat与stat类似,但当命名文件是一个符号链接时,lstat返回该链接对应的有关信息而不是链接指向的文件。

  fstatat返回相对于当前打开目录(由fd参数指向)的路径名的文件统计信息。flag参数控制是否跟随着一个符号链接。当AT_SYLINK_NOFOLLOW被置位时,不跟随符号链接,只返回符号链接本身的文件信息。否则,在默认情况下,返回的是符号链接指向的文件对于的信息。如果fd参数是AT_FDCWD,并且pathname是一个相对路径,则会计算相对于当前目录的pathname参数,返回对应文件信息。如果pathname是绝对路径,fd会被忽略。

  buf是一个指针,指向我们必须提供的结构。函数来填充内容。结构的基本形式为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct stat{
mode_t st_mode; //文件类型和mode(权限)
ino_t st_ino; //i节点数量
dev_t st_dev;//设备数量
dev_t st_rdev;//对于特殊文件来说设备数量
nlink_t st_nlink;//链接数量
uid_t st_uid;//拥有者用户id
gid_t st_gid;//拥有者组id
off_t st_size;//大小(bytes)
struct timespec st_atime;//最后一次访问时间
struct timespec st_mtime;//最后一次更改时间
struct timespec st_time;//最后一次文件stat更改时间。
blksize_t st_blksize;//best I/O block size
blkcnt_t st_blocks;//number of disk bocks allocated
}

timespec结构类型按照秒和纳秒定义了时间,至少包含以下两个字段:

1
2
time_t tv_sec;
long tv_nsec;

stat中使用到的类型大都属于基本统计类型。使用前

1
include<sys/types.h>

文件类型

文件类型 说明
普通文件 最常见的文件,包含某种形式数据。
目录文件 包含了其他文件的名字以及指向这些文件有关信息的指针。对于一个目录文件具有度权限的任一进程都能读取目录内容,但只有内核能够写目录文件。
块特殊文件 提供对设备带缓冲的访问,每次访问以以固定长队为单位进行。
字符特殊文件 提供对设备不带缓冲的访问,每次访问长度可变。系统中的所以设备要么是字符特殊文件,要么是块特殊文件。
FIFO(管道) 用于进程间通讯。
套接字(socket) 用于进程间网路通信。
符号连接。 指向另一个文件。

文件类型信息包含在stat结构中的st_mode中。使用如下宏来确定文件类型:参数均为stat结果中的st_mode。

S_ISREG() 普通文件
S_ISDIR() 目录文件
S_ISCHR() 字符特殊文件
S_ISBLK() 块特殊文件
S_ISFIFO() 管道
S_ISLNK() 符号连接
S_ISSOCK() 套接字

设置用户ID和组ID

一个进程关联的ID有六个或更多。

实际用户ID/实际组ID 我们实际上是谁
有效用户ID/有效组ID/附属组ID 用于文件访问权限检查
保存的设置用户ID/保存的设置组ID 由exec函数保存

通常有效用户ID等于实际用户ID,有效组ID等于实际组ID。所以者和所有者组由stat中st_uid和st_gid指定。

实际用户ID和实际组ID表示我们究竟是谁.这两个字段在登录时取自口令文件的登录项(应该是由执行该文件的用户决定).

当执行一个程序文件时,通常进程的有效用户ID就是实际用户ID,有效组ID通常是实际组ID. 但我们可以在文件模式字(st_mode)中设置一个标志,其含义是”当执行次文件时,将进程有有效用户ID设置为文件所有者的用户ID”,与次类似,在文件模式字中,可以设置另一位,它将执行文件的进程的有效组ID设置为文件所有者组ID.这两个位分别为设置用户ID位(set-user-id)和设置组ID位(set-group-ID).

文件访问权限

st_mode值也包含了对文件的访问权限.这里的文件是指上述所有七种文件.

每个文件有几个访问位权限,可以分为三类:

st_mode屏蔽 含义
S_IRUSER/S_IWUSER/S_IXUSE 用户读/写/执行
S_IRGRP/S_IWGRP/S_IXGRP 组读/写/执行
S_IROTH/S_IWORT/S_IXOTH 其他读/写/执行

用户指的是所有者.chomd命令用来修改这九个权限.该命令允许我们用u表示用户,用g表示组,用o表示其他.

使用规则:

当我们使用名字打开一个文件时,我们对该名字中包含的每一个目录,包括它可能隐藏的当前的工作目录都应该具有执行权限.这也是为何对目录执行权限位通常被称为搜索位.

注意:对于目录的读权限和执行权限的意义是不同的.读权限允许我们读目录,获得在该目录下所以文件名的列表.当一个目录是我们要访问文件路径名的一部分时,对该目录的执行权限使得我们可以通过该目录.

对于一个文件的读权限决定了我们能否打开文件进行读操作.

对于一个文件的写权限决定了我们能否打开文件进行写操作.

为了在open函数中对一个文件指定O_TRUNC标志,必须对该文件具有写权限.

为了在一个目录下创建一个新文件,需要对该目录具有写和执行权限.

为了删除一个文件,需要对该文件所在目录具有写和执行权限而不必对文件本身具有相应权限.

如果使用七个exec函数执行某个文件,需要对该文件具有执行权限.

进程每次打开,创建,删除一个文件时,内核就会进行文件访问权限测试,而这种测试可能涉及文件所有者(st_uid和st_gid),进程的有效ID(有效用户ID和有效组ID)已经进程的附属组ID.两个所有者ID是文件的性质,而两个有效ID和附属ID则是进程的性质. 内核测试具体如下:

  1. 若进程有效ID是0(超级用户),则允许访问.
  2. 若进程的有效用户ID等于文件所有者ID(即进程拥有此文件),则判断所有者是否具有进程将要操作的权限,如果没有则拒绝.
  3. 若进程的有效组ID或进程的附属组ID之一等于文件的组ID,那么组适当的权限被置位则允许访问.
  4. 若其他用户适当的访问权限被置位,则允许访问.

按顺序执行这四步.需要注意,这四步是截断的,即一个条件被满足就不会继续向下进行.

新文件和目录的所有权

新文件的用户ID设置为进程的有效用户ID,新文件的组ID可以是进程的有效组ID,也可以是它所在目录的组ID.

函数access和faccess

access和faccess是按照进程实际用户ID和实际组ID进行权限测试的.

函数原型:

1
2
3
4
#include<unistd.d>
int access(const char *pathname, int mode);
int faccessat(int fd, const char *pathname, int mode, int flag);
//成功返回0,失败返回-1

当要测试文件是否存在时,mode是F_OK,否则mode是下面常量按位或.

mode
R_OK 测试读权限
W_OK 测试写权限
X_OK 测试执行权限

当pathname是绝对路径和当fd是AT_FDCWD而pathname是相对路径时,faccessat与acess是相同的.

flag参数可以用于改变faccessat行为,如果flag设置为AT_ACCESS访问检测用的是有效用户ID和有效进程ID.

例:acess.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include"apue.h"
#include<fcntl.h>
int main(int argc, char *argv[])
{
if(argc!=2)
{
err_quit("usage: a.out<pathname>");
}
if(access(argv[1], R_OK)<0)
{
err_ret("access error for%s",argv[1]);
}
else
{
printf("read access ok\n");
}
if(open(argv[1], O_RDONLY)<0)
{
err_ret("open error for %s",argv[1]);
}
else
{
printf("open for reading ok\n");
}
exit(0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$g++ access.cpp -o access.o -lapue
$ls -l access.o
-rwxrwxr-x 1 chst chst 13328 Sep 8 15:40 access.o
$./access.o access.cpp
read access ok
open for reading ok
$ls -l /etc/shadow
-rw-r----- 1 root shadow 1395 May 6 19:00 /etc/shadow
$ ./access.o /etc/shadow
access error for/etc/shadow: Permission denied
open error for /etc/shadow: Permission denied
$ sudo chown root access.o //更改文件用户为超级用户
[sudo] password for chst:
$ sudo chmod u+s access.o //打开设置用户位,即使得进程的有效ID等于文件的用户ID,即超级用户.
$ ls -l access.o
-rwsrwxr-x 1 root chst 13328 Sep 8 15:40 access.o //这里s表示设置用户位被置位
$exit //退出超级用户
$ ./access.o /etc/shadow
access error for/etc/shadow: Permission denied
open for reading ok

这里解释一下最后的输出,在执行access.o时,我们是以普通用户进行的,此时进程的实际ID即为普通用户ID,但由于设置用户位被置位,此时进程的有效用户ID为超级用户ID,因为在打开文件时,是使用有效用户来进行判断的,因此此时可以打开文件,但是我们实际用户ID是普通用户,因此使用access进行检查时,会显示权限错误,因此尽管我们可以打开文件,但可以确定实际用户不能正常读指定文件.但该程序现在是可以正常读取指定文件的.

函数umask(文件模式创建屏蔽字)

umask函数为进程设置文件模式创建屏蔽字,并返回之前的值.

函数原型:

1
2
#include<sys/stat.h>
mode_t umask(mode_t cmask);

其中cmask为之前表格里面的9个常量或的结果.在进程创建一个新文件和新目录时,就一定会使用文件模式创建屏蔽字.(open和creat函数都有参数mode,其就是用来指定新文件的访问权限).

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include"apue.h"
#include<fcntl.h>
#define RWRWRW (S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH)
int main()
{
umask(0);
if(creat("foo", RWRWRW)<0)
{
err_sys("creat foo error!\n");
}
umask(S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH);
if((creat("bar", RWRWRW)<0))
{
err_sys("creat bar error!\n");
}
exit(0);
}

执行

1
2
3
4
5
6
chst@wyk-GL63:~/study_file/unix编程$ umask //查看当前屏蔽字, 0002表示只有其他写被屏蔽
0002
$ ./umask.o
$ ls -l foo bar
-rw------- 1 chst chst 0 Sep 8 17:34 bar
-rw-rw-rw- 1 chst chst 0 Sep 8 17:34 foo

更改环境文件创建屏蔽字:

1
2
3
4
5
$umask -S //打印符号格式
u=rwx,g=rwx,o=rx
$umask 0027 //更改屏蔽字,屏蔽用户组读和其他的所以权限
$umask -S
u=rwx,g=rx,o=

函数chmod,fchmod和fchmodat

这三个函数使得我们可以更改现有文件的访问权限.

函数原型:

1
2
3
4
#include<sys/stat.h>
int chmod(const char *pathname, mode_t mode);
int fchmod(int fd, mode_t mode);
int fchmodat(int fd, const char *pathname, mode_t mode, int flag)

chmod操作指定文件,fchmod操作打开的文件, 当pathname为绝对路径或fd参数为AT_FDCWD而pathname为相对路径时,fchmodat与chmod一样. flag参数用来改变fchmodat行为,当设置了AT_SYMLIN_NOFOLLOW标志时,fchmodat不会跟随符号链接.

为了改变一个文件的权限位,进程的有效用户ID必须等于文件所有者ID,或者进程拥有超级用户权限.参数mode是如下常量取与或:

mode 说明
S_ISUID 执行时设置用户ID
S_ISGID 执行时设置用户组ID
S_ISVTX
S_IRWXU 用户(所有者)读写和执行
S_IRUSR 用户读
S_IWUSR 用户写
S_IXUSR 用户执行
S_IRWXG 用户组读写和执行
S_IRGRP 用户组读
S_IWGRP 用户组写
S_IXGRP 用户组执行
S_IRWXO 其他读写和执行
S_IROTH 其他读
S_IWOTH 其他写
S_IXOTH 其他执行

命令行添加设置用户ID和设置组ID方式为:

1
2
3
$chmod u+s filename
$chmod g+s filename
-rwSrwSrw- //这里S表示设置ID开启.

函数chown,fchown,fchownat和lchown

这几个函数是用来更改文件用户ID和组ID的.函数原型:

1
2
3
4
5
#include<unistd.h>
int chown(const char *pathname, uid_t owner, gid_t group);
int fchown(int fd, uid_t owner, gid_t group);
int fchownat(int fd, const char *pathname, uid_t owner, gid_t group, int flag);
int lchown(const char *pathname, uid_t owner, gid_t group);

当owner或group任意一个是-1,则对应的ID不变.

除了所引用的文件是符号链接以外,这4个函数操作类似.在符号连接下,lchown与fchownat(设置了AT_SYMLINK_NOFOLLOW)更改符号链接本身而不是连接指向的文件.

当pathname为绝对路径或fd参数为AT_FDCWD而pathname为相对路径时,fchownat与chmod一样. flag参数用来改变fchmodat行为,当设置了AT_SYMLIN_NOFOLLOW标志时,fchmodat不会跟随符号链接.

文件系统

在说明文件链接前先介绍一下文件系统,这里主要介绍的是UFS系统.

我们把一个磁盘分成一个或多个分区,每个分区都包含一个文件系统.细节见下图:

文件系统

仔细观察柱面i节点和数据块的部分,会存在下图的关系:

i节点

注意细节:

  1. 图中有两个目录项指向同一个i节点.每个i节点都有一个连接计数,其值是指向该i节点的目录项数.只有当链接计数等于0的时候才可以删除文件(释放该文件所占用的数据块).在stat中,链接计数包含在st_nlink中,基本数据类型是nlink_t.这种链接为硬链接.
  2. 还有一种链接为符号链接.符号链接链接文件的实际内容(在数据块中)包含了该符号链接所执向的文件的名字.
  3. i节点包含了文件的所以信息:文件类型,访问权限,文件长度和指向文件数据块的指针等. stat中大多数内容取自i节点,只有文件名和i节点编号放在目录项中.
  4. 当在不更换文件系统的情况下为一个文件重命名时,该文件实际内容并未移动,只需要构造一个指向当前i节点的新目录项,并删除老目录项即可,连接计数不会改变.

目录文件的计数说明:

使用mkdir testdir创建一个新目录时,结果如下:

创建目录

该图显示的展现出了....

任何一个叶目录(不包含目录的目录)连接计数均为2.数值2来自于命名该目录的目录项和在该目录中的..编号为1267的i节点,链接计数大于等于3.这是由于,一个是命名它的目录项,一个是自己目录下的.,还有则是新建的目录testdir中的..(目录下的目录中的..都是对父目录的硬链接,会增加i节点计数).

使用link和linkat函数创建一个指向当前文件的链接.函数原型:

1
2
3
4
#include<unistd.h>
int link(const char *existingpath, const char *newpath);
int linkat(int efd, const char *existingpath, int nfg, const char *newpath, int flag);
// 函数返回值0正常,-1出错.

两个函数创建一个新目录项newpath,它引用现有文件existingpath. 创建新目录项和增加链接计数应该是原子操作.

为了删除一个现在的目录项, 可以使用unlink和unlinkat函数:

1
2
3
#include<unistd.h>
int unlink(const char *pathname);
int unlink(int fd, const char *pathname, int flag);

两个函数删除目录项,并将有pathname所引用文件的链接计数减一.为了解除对文件的链接,我们必须对该目录具有写和执行权限.只有当链接计数达到0的时候该文件内容才会被删除.

注意:只要有进程打开了该文件,其内容也不会被删除.关闭一个文件时,内核首先检测打开该文件的进程数目,如果这个数值等于0,再去查看链接计数,如果链接计数也达到0,才删除文件.利用该特性,unlink常常被用来确保在程序崩溃的情况下删除临时创建的文件.进程使用open或creat创建一个文件,然后立即调用unlink,由于该文件仍旧是打开的,因此不会被立即删除,只有当进程终止时,文件才会被删除.

fd和pathname用来确定路径的.flag给出一种方式,当AT_REMOVEDIR被设置时,unlinkat函数类似与rmdir一样删除目录.

如果pathname给出的是符号链接,则只能删除符号链接本身,当是符号链接时,没有能够直接删除符号链接所引用的文件的函数.

可以使用remove解除对一个文件或目录的链接. 对于目录,remove与rmdir类型,对于文件,remove与unlink类型.

函数原型:

1
2
#include<stdio.h>
int remove(const char *pathname);

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include"apue.h"
#include<fcntl.h>
int main()
{
if(open("temp.foo", O_RDWR)<0)
err_sys("open eror");
if(unlink("temp.foo")<0)
{
err_sys("unlink error");
}
printf("file unlinked\n");
sleep(15);
printf("done\n");
exit(0);
}

运行:

1
2
3
4
5
6
7
8
9
10
chst@wyk-GL63:~/study_file/unix编程$ ls -l temp.foo
-rw-rw-r-- 1 chst chst 8 Sep 10 23:59 temp.foo
chst@wyk-GL63:~/study_file/unix编程$ ./unlink.o & //后台运行程序
[1] 9597
chst@wyk-GL63:~/study_file/unix编程$ file unlinked
done
ls -l temp.foo
ls: cannot access 'temp.foo': No such file or directory //目录项已被删除(数据块未被删除)
[1]+ Done ./unlink.o
chst@wyk-GL63:~/study_file/unix编程$

函数rename和renameat

文件或目录可以使用rename和renameat来命名,函数原型:

1
2
3
#include<stdio.h>
int rename(const char *oldname, const char *newname);
int renameat(int oldfd, const char *oldname, int newfd, const char *newname);

符号链接

符号链接是对一个文件的间接指针,它与上一节的硬链接(直接指向i节点)不同,符号链接指向的是目录项.这是为了规避硬链接的一些限制:

  1. 硬链接通常要求链接和文件位于同一文件系统下.
  2. 只有超级用户才能创建指向目录的硬链接.

对符号链接以及它指向何种对象并无任何限制,任何用户都可以创建指向目录的符号链接.符号链接是为了将一个文件或整个目录移到系统的另一个位置.

使用符号链接可能造成循环:

1
2
3
4
5
6
7
chst@wyk-GL63:~/study_file/unix编程$ mkdir loop
chst@wyk-GL63:~/study_file/unix编程$ touch loop/a //创建一个空文件a
chst@wyk-GL63:~/study_file/unix编程$ ln -s ../loop loop/testdif //在目录下创建一个符号链接指向目录本身
chst@wyk-GL63:~/study_file/unix编程$ ls -l loop
total 0
-rw-rw-r-- 1 chst chst 0 Sep 11 00:37 a
lrwxrwxrwx 1 chst chst 7 Sep 11 00:37 testdif -> ../loop

此时就会造成循环,因为目录下的符号链接指向目录本身.

Screenshot from 2019-09-11 00-40-23

此时使用Solares中的ftw以降序遍历文件结构,打印每一个遇到的路径名,结果为:

Screenshot from 2019-09-11 00-40-42

这个循环是十分容易消除的,因为unlink不跟随符号链接,可以使用unlink文件foo/testdir.但如果创建一个构成这样的硬链接,就很难消除(难吗?直接删除testdir不就好了?).因此link不允许一般用户(linux下超级用户也不行)构造指向目录的链接.

创建和读取符号连接

可以使用symlink或symlinkat函数创建一个符号链接.函数原型:

1
2
3
4
5
#include<unistd.h>
int symlink(const char *actualpath, const char *sympath);
int symlinkat(const char *actualpath,int fd,const char *sympath);
//两个函数成功返回0,出错返回-1
// // 创建符号链接$ln -s actualpath sympath

函数创建一个指向actualpath的新目录项sympath.并不要求actualpath已经存在,且两个不必在同一个文件系统中.

open函数会打开链接指向内容,因此需要一种方式打开链接本身,并读该链接中的名字.函数readlink和readlinkat提供这一功能.函数原型:

1
2
3
4
#include<unistd.h>
ssize_t readlink(const char *restatrict pathname,char *restrict buf,size_t bufsize);
ssize_t readlinkat(int fd,const char *pathname,char *restrict buf, size_t bufsize);
//成功返回buf中读取字节数,否则返回-1

两个函数组合了open,read,和close的所有操作.buf返回的符号链接不以null为结尾.

文件时间

每个文件维护三个时间字段:

字段 说明 例子 ls选项
st_atim 文件数据最后访问时间 read -u
st_mtim 文件数据的最后修改时间 write 默认
st_ctim i节点最后的更该时间 chmod,chown -c

注意: 修改时间(st_mtim)与状态更改时间(st_ctim)的区别.修改时间是指文件内容修改时间(数据块),状态更改时间是该文件i节点最后被修改时间.状态更改时间包括更改访问权限,用户ID,连接计数.

函数futimens,utimensat,utimes函数

函数原型:

1
2
3
#include<sys/stat.h>
int futimens(int fd, const struct timespec times[2]);
int utimenstat(int fd,const char *path,const struct timespec times[2], int flag);

这两个函数用于更改文件访问和修改时间.times数组参数第一个元素包含访问时间,第二个元素包含修改时间,均是时间戳.

时间戳按照下列四种方式之一进行指定:

  1. 如果times参数是空指针,则访问时间和修改时间都设置为当前时间.
  2. 如果times指向两个timespec结构的数组,任一数组元素的tv_nesc字段值为UTIME_NOW,相应的时间戳就设置为当前时间,忽略相应的tv_sec字段.
  3. 如果times指向两个timespec结构的数组,任一数组元素的tv_nesc字段值为UTIME_OMIT,相应的时间戳保持不变,忽略相应的tv_sec字段.
  4. 如果times指向两个timespec结构的数组,任一数组元素的tv_nesc字段值即不为UTIME_OMIT也不是UTIME_NOW,相应的时间戳设置为对应的两个字段值.

utims对目录名时间进行操作,函数原型:

1
2
3
4
5
6
#include<sys/time.h>
int utimes(const char *pathname, const struct timeval times[2]);
struct timeval{
time_t tv_sec;
long tv_usec;//毫秒
}

我们不能更改状态更改时间st_ctim指定一个值,因为调用这三个函数时,此字段会被自动更新.

函数mkdir,mkdirat和rmdir

用mkdir,mkdirat,用rmdir函数删除目录.函数原型:

1
2
3
#include<sys/stat.h>
int mkdir(const char *pathname,mode_t mode);
int mkdirat(int fd,const char *pathname, mode_t mode);

两个函数创建一个新的空目录.其中...被自动创建.所指定的文件访问权限mode由进程的文件模式创建屏蔽字修改.常见错误是指定与文件一样的mode(只指定读写).对于目录来说,者少应该添加执行权限来允许访问目录中的文件名.

使用rmdir函数删除一个空目录:

1
2
#include<unsid.h>
int rmdir(const char *pathname);

如果调用该命令使得目录的链接计数达到0,并且也没有进程打开该目录,则释放次目录占用的空间.如果此时有进程打开该目录,则在进程结束前删除最后一个链接及...,在此目录下不能创建文件,但在最后一个打开该目录的进程结束前不会释放次目录.

读目录

对某个目录具有访问权限的任意用户都可以读目录,但只有内核可以写目录.一个目录的写权限决定了在该目录下能否创建新文件以及删除文件,它们不代表能否写目录本身.

相关函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<dirent.h>
DIR *opendir(const char *pathname);
DIR *fdopendir(int fd);
//成功返回指针,出错返回NULL

struct dirent *readdir(DIR, *dp);
//成功返回指针,出错返回NULL

void rewinddir(DIR *dp);

int closedir(DIR *dp);
//成功返回0,错误返回-1

long telldir(DIR *dp);
//返回与dp关联的目录中的当前位置

void seekdir(DIR *dp,long loc);

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
#include"apue.h"
#include<dirent.h>
#include<limits.h>

//一个静态函数,传参为函数指针
static int myftw(char *, int (*Myfunc)(const char *, const struct stat *, int));
static int dopath(int (*Myfunc)(const char *, const struct stat *, int));
static long nreg, ndir, nblk, nchr, nfifo, nslink, nsock, ntot;
static int Myfunc(const char *pathname, const struct stat *statprt, int type);

int main(int argc, char *argv[])
{
int ret;
if(argc != 2)
{
err_quit("usage: ftw <starting-pathname>");
}
ret = myftw(argv[1], Myfunc);
ntot = nreg + ndir + nblk + nchr + nfifo + nslink + nsock;
// 避免除以0
if(ntot == 0)
{
ntot = 1;
}
printf("regular file = %7ld, %5.2f%%\n", nreg, nreg*100.0/ntot);
printf("directories = %7ld, %5.2f%%\n", ndir, ndir*100.0/ntot);
printf("block special = %7ld, %5.2f%%\n", nblk, nblk*100.0/ntot);
printf("char special = %7ld, %5.2f%%\n", nchr, nchr*100.0/ntot);
printf("FIFOs = %7ld, %5.2f%%\n", nfifo, nfifo*100.0/ntot);
printf("symbolic link = %7ld, %5.2f%%\n", nslink, nslink*100.0/ntot);
printf("sockets = %7ld, %5.2f%%\n", nsock, nsock*100.0/ntot);
exit(ret);
}

#define FTW_F 1 //普通文件
#define FTW_D 2 //目录
#define FTW_DNR 3 //无法读取的目录
#define FTW_NS 4 //没有stat的文件

static char *fullpath; //文件绝对路径
static size_t pathlen;

static int myftw(char *pathname, int (*Myfunc)(const char *, const struct stat *, int))
{
fullpath = (char*)malloc(PATH_MAX+1); //分配PATH_MAX+1字节
if(pathlen<=strlen(pathname))
{
pathlen = strlen(pathname) * 2;
fullpath = (char*)malloc(pathlen);
}
strcpy(fullpath, pathname);
printf("fullpath1 = %s\n", fullpath);
return dopath(Myfunc);
}

//深度优先搜索DFS,遍历每个文件和目录
static int dopath(int (*Myfunc)(const char *, const struct stat *, int))
{
struct stat statbuf;
struct dirent *dirp;
DIR *dp;
int ret, n;

//递归终止的2个条件,1:文件相关信息无法获取, 2:传递的绝对路径不是目录而是文件
if(lstat(fullpath, &statbuf)<0)
{
return Myfunc(fullpath, &statbuf, FTW_NS);
}
if(S_ISDIR(statbuf.st_mode)==0)
{
return Myfunc(fullpath, &statbuf,FTW_F);
}

//如果fullpath是目录
//如果返回表示0表示出错
if((ret=Myfunc(fullpath, &statbuf, FTW_D)) != 0)
{
return ret;
}
n = strlen(fullpath);
printf("fullname long = %d\n",n);
if(n+NAME_MAX+2>pathlen)
{
pathlen *= 2;
char *save = fullpath;
fullpath = (char*)malloc(pathlen);
for(int j=0;j<n;j++)
{
fullpath[j] = save[j];
}
}
fullpath[n++]='/';
fullpath[n] = 0;

printf("fullpath2=%s\n", fullpath);
if((dp = opendir(fullpath)) == NULL)
{
printf("fullpath3=%s\n", fullpath);
return Myfunc(fullpath, &statbuf, FTW_DNR);
}

// 逐个获取目录下文件名,直到空表示终止
while ((dirp = readdir(dp))!=NULL)
{
if(strcmp(dirp->d_name, ".") == 0 || strcmp(dirp->d_name, "..")==0)
{
continue;
}
strcpy(&fullpath[n], dirp->d_name);
if((ret=dopath(Myfunc))!=0)
{
break;
}
}
fullpath[n-1] = 0; //恢复初始路径,深度优先搜索回溯
if(closedir(dp)<0)
{
err_ret("can't close directory %s", fullpath);
}
return ret;
}

static int Myfunc(const char *pathname, const struct stat *statprt, int type)
{
switch (type)
{
case FTW_F:
switch (statprt->st_mode & S_IFMT)
{
case S_IFREG: nreg++;break;
case S_IFBLK: nblk++;break;
case S_IFCHR: nchr++;break;
case S_IFIFO: nfifo++;break;
case S_IFLNK: nslink++;break;
case S_IFSOCK: nsock++;break;
case S_IFDIR: err_dump("for S_IFDIR for %s", fullpath);
}
break;
case FTW_D:
ndir++;
break;
case FTW_DNR:
err_ret("can't read1 directory %s", fullpath);
break;
case FTW_NS:
err_ret("stat error for %s", fullpath);
break;
default:
err_dump("unknown type %d for pathname %s", type, fullpath);
break;
}
return 0;
}
1
2
3
4
5
6
7
8
$ ./readdir.o /home
regular file = 321788, 91.13%
directories = 25916, 7.34%
block special = 0, 0.00%
char special = 0, 0.00%
FIFOs = 1, 0.00%
symbolic link = 5390, 1.53%
sockets = 0, 0.00%

函数chdir,fchdir和getcwd

每个进程都有一个当前工作目录,此目录是搜索所有相对路径的起点.当用户登录到UNIX时,器当前工作目录通常是口令文件(/etc/passwd)中该用户登录项的第六个字段—用户起始目录.当前工作目录是进程的一个属性,起始目录则是登录名的一个属性.进程调用chdir或fchdir函数更改当前工作目录:

1
2
3
4
#include<unistd.h>
int chdir(const char *pathname);
int fchdir(int fd);
//返回0表示成功,返回-1表示错误.

获取当前工作目录的绝对路径:

1
2
3
#include<unistd.h>
char *getpwd(char *buf, size_t size);
//成功返回buf,失败返回NULL

参数buf是缓冲区地址,size是缓冲区长度,缓冲区必须有足够长度以容纳绝对路径名再加上一个null字节.

第五章 标准I/O库

流和FILE对象

对于标准I/O库,操作都是围绕流进行的.当用标准库打开或创建一个文件时,我们已近使用一个流与其关联.

流的定向决定了所读写的是单字节还是多字节.如若在未定向的流上使用多字节I/O函数,则将该流的定向设置为宽定向的,若在未定向的流上使用一个单字节I/O函数,则将该该流设置为字节定向的.

fwide函数用于设置流的定向:

1
2
3
4
#include<stdio.h>
#include<wchar.h>
int fwide(FILE *fp, int mode);
//宽定向返回正值,字节定向返回负值,为定向返回0

mode为负值,试图将流指定为字节定向,mode为正值,试图将流指定为宽定向,mode为0,不指定定向.

fwide不改变已定向的流的定向.

当打开一个流时,标准I/O函数fopen返回一个指向FILE对象的指针.该对象通常是一个结构,它包含了标准I/O库为管理该流需要的所有信息,包括用于实际I/O的文件描述符,指向用于该缓冲区的指针,缓冲区的长度,当前在缓冲区的长度以及出错标志等.

标准输入,标准输出与标准错误

对一个进程预定义了三个流,标准输入,标准输出与标准错误.这三个流进程可以自动使用.

这三个标准I/O通过预定义文件指针stdin,stdout.stderr加以引用,这三个文件指针被定义在头文件中.

缓冲

标准I/O库提供缓冲的目的是为了尽可能的减少使用read和write次数.标准库提供了三种缓冲类型.

(1)全缓冲.在这种情况下,在填满标准I/O缓冲区后才进行实际I/O操作.在一个流上第一次执行I/O操作时,相关标准I/O函数通常调用malloc获得需要的缓冲区.

  术语冲洗说明标准I/O写操作.缓冲区可向标准I/O自动冲洗,或者可以调用fflush冲洗一个流.flush存在两种意思,在I/O方面,flush表示将缓冲区写入磁盘,在终端驱动程序方面,flash表示丢弃已存储在缓冲区的数据.

(2)行缓冲. 在输入和输出遇到换行符时,标准I/O库执行I/O操作.这允许我们一次输出一个字符,但只在写了一行后才进行实际I/O操作.终端中(涉及标准输入输出),通常使用行缓冲.

  对于行缓冲通常有两个限制.第一:I/O库的缓冲区是有限制的,如果一行太长,填满了缓冲区,即使没有到达换行符,也进行I/O操作.第二:任何时候,通过标准I/O库要求从(a)一个不带缓冲的流,或者(b)一个行缓冲流得到数据,那么就会冲洗所以行输出流.

(3)不带缓冲.标准I/O不对字符进行缓冲存储.标准错误流stderr通常是不带缓冲的,这就是使得错误信息可以立即显式出来.

ISO C要求缓冲特征:

  1. 当且仅当标准输入和标准输出并不指向交互设备时,他们才是全缓冲的.
  2. 标准错误绝不是全缓冲的.

一般系统默认缓冲类型:

  1. 标准错误是不带缓冲的.
  2. 若是指向终端设备的流,则是行缓冲的,否则是全缓冲.

可以使用下列两个函数更改缓冲类型:

1
2
3
4
#include<stdio.h>
void setbuf(FILE *restrict fp, char *restrict buf);
int setvbuf(FILE *restrict fp, char *restrict buf,int mode, size_t size);
//返回0成功,否则失败

参数解释:

函数 mode buf 缓冲区及长度 缓冲类型
setbuf 非空 长度为BUFSIZ的用户缓冲区buf 全缓冲或行缓冲
setbuf NULL 无缓冲区 不带缓冲
setvbuf _IOFBF 非空 长度为size的缓冲区buf 全缓冲
setvbuf _IOFBF NULL 合适长度的系统缓冲区buf 全缓冲
setvbuf _IOLBF 非空 长度为size的缓冲区buf 行缓冲
setvbuf _IOLBF NULL 合适长度的系统缓冲区buf 行缓冲
setvbuf _IONBF 忽略 无缓冲 不带缓冲

任何时候,我们可以强制刷新一个流:

1
2
3
#include<stdio.h>
int fflush(FILE *fp);
//成功返回0,否则返回EOF

打开流

函数原型:

1
2
3
4
#include<stdio.h>
FILE *fopen(const char *restrict pathnem, const char *restrict type);
FILE *freopen(const char *restrict pathnem, const char *restrict type,FILE *restrict fp);
FILE *fdopen(int fd,const char *type);

fopen打开路径名为pathname的文件.

freopen在一个指定流上打开文件,如果流已经被打开,则先关闭该流.如果流已经定向,则清除定向,此函数通常将一个指定的文件绑定到一个指定的流上:标准输入输出错误.

fdopen取一个文件描述符,并使一个标准I/O流与该描述符结合.此函数通常用于创建管道和网路通信通道函数返回的描述符.

type有15种取值:

type 说明 open标准
r/rb 为读而打开 O_RDONLY
w/wb 把文件截断为0长,或为写而创建 O_WRONLY\ O_CREAT\ O_TRUNC
a/ab 追加:为在文件尾写而打开,或为写而创建 O_WRONLY\ O_CREAT\ O_APPEND
r+/r+b/rb+ 为读和写创建 O_RDONLY
w+/w+b/wb+ 把文件截断为0长,或为写而创建 O_WRONLY\ O_CREAT\ O_TRUNC
a+/a+b/ab+ 追加:为在文件尾写而打开,或为写而创建 O_WRONLY\ O_CREAT\ O_APPEND

调用fclose关闭一个流:

1
2
3
#include<stdio.h>
int fclose(FILE *fp);
//成功返回0,否则返回EOF

读和写流

打开流后,可以使用三种不同类型的非格式化I/O对其进行读写操作.

(1)每次一个字符的I/O

(2)每次一行的I/O

(3)直接I/O.fread和fwrite函数支持这种类型I/O.常用于从二进制文件中每次读写一个结构.

输入函数(一次一个字符)

1
2
3
4
5
#include<stdio.h>
int getc(FILE *fp);
int fgetc(FILE *fp);
int getchar(void);
//若成功返回下一个字符,若已到达文件末尾或出错,返回EOF

函数getchar等于getc(stdin)(标准输入).前两个函数的区别是,getc可被实现为宏,而fgetc不能.

这三个函数在返回下一个字符时,将其unsigned char转换为int.要求返回整型的原因是,这样就可以返回所以可能的字符再加上一个出错或者到达文件末尾的指示值. EOF通常是一个负值,一般是-1.

不管出错还是到达文件末尾,三个函数都是返回相同的值,这时候想要区分就需要调用下面的函数:

1
2
3
4
5
6
#include<stdio.h>
int ferror(FILE *fp);
int feof(FILE *fp);
//函数返回非0,表示为真,否则为假

void cleareer(FILE *fp);

每个流在FILE对象中维护了两个标志:

  1. 出错标志
  2. 文件结束标志

调用cleareer可以清除这两个标志.

输出函数(一次一个字符)

1
2
3
4
5
#include<stdio.h>
int putc(int c,FILE *fp);
int fputc(int c, FILE *fp);
int putchar(int c);
//成功返回c,否则返回EOF

puchar(c)等于putc(c,stdout).

每次一行I/O

输入一行

1
2
3
4
#include<stdio.h>
char *fgets(char *restrict buf, int n, FILE *restrict fp);
char *gets(char *buf);
//成功返回buf,到达文件末尾或出错返回NULL

gets从标准输入中读取,fgets从指定流中读取.fgets需要指定缓冲的长度n.此函数一直到下一个换行符为止,但不超过n-1个字符,读入的字符被送入缓冲区.缓冲区总是以null字节结尾.对于超过n-1个字符的行,fgets只返回一个不完整的行,下次调用会继续处理这一行.

gets不能指定缓冲区长度,不推荐使用.gets和fgets的一个区别是,gets并不将换行符存入缓冲区中.

输出一行

1
2
3
4
#include<stdio.h>
int fputs(const char *restrict str, FILE *restrict fp);
int puts(const char *str);
//成功返回非负值,到达文件末尾或否则返回EOF

函数fputs将一个以null字节作为结尾的字符串写到指定的流,尾端的null不写出.fputs不一定是一次输出一行,因为字符串不必最后一个非null字符为换行符.

输入输出举例

按字节输入输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include"apue.h"
static int count = 0;
int main(void)
{
int c;
while((c=fgetc(stdin))!=EOF)
{
count++;
if(fputc(c,stdout)==EOF)
{
err_sys("output error\n");
}
}
if(ferror(stdin))
{
err_sys("input error\n");
}
printf("count=%d\n",count);
}

由count可以看出,标准输入输出行缓冲的时候会根据换行符作为终止,同时会将换行符传入流中.

按行输入输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include"apue.h"
#include"string.h"
int main(void)
{
char buf[MAXLINE];
while(fgets(buf,MAXLINE,stdin)!=NULL)
{
int num = strlen(buf);
printf("strlen = %d\n",num);
if(fputs(buf,stdout)==EOF)
{
err_sys("output error\n");
}
}
if(ferror(stdin))
{
err_sys("input error\n");
}
exit(0);
}

由于strlen不计算字符串末尾的空字符,因此通过count我们也能发现按行读取时,换行符会被读到标准输入,这是我们如果将末尾的换行符替换成空字符,输出就不是按行了.

二进制I/O

二进制I/O主要用于一次读写一个结构.下面两个函数提供了二进制I/O操作

1
2
3
4
#include<stdio.h>
size_t fread(void *restrict ptr,size_t size, size_t nobj, FILE *restrict fp);
size_t fwrite(const void *restrict ptr, size_t size, size_t nobj, FILE *restarict fp);
//函数返回值为读写对象的数量.

这两个函数有以下两种常见用法.

(1)读或写一个二进制数组.如将一个浮点数组的第2-5个元素写到一个文件.

1
2
3
float data[10];
if(fwrite(&data[1],sizeof(float),4,fp)!=4)
err_sys("fwrite error\n");

(2)读或写一个结构

1
2
3
4
5
6
7
8
struct{
short count;
long total;
char name[NAMESIZE];
} item;

if(fwrite(&item,sizeof(item),1,fp)!=1)
err_sys("fwrite error\n");

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include<stdio.h>
#include<string.h>
#include"apue.h"
#define NAME_SIZE 20
struct bio
{
int score;
char name[NAME_SIZE];
};

int main(int argc, char *argv[])
{
bio self;
FILE *fp = fopen(argv[1], "w");
char name[] = "chst";
for(int i=0;i<strlen(name);i++)
{
self.name[i] = name[i];
}
self.name[strlen(name)] = 0;
self.score = 30;
if(fwrite(&self,sizeof(bio),1,fp)!=1)
{
err_sys("fwrite error\n");
}
fclose(fp);
FILE *fd = fopen(argv[1], "r");
bio self2;
if(fread(&self2,sizeof(bio), 1, fp)!=0)
{
printf("name:%s,score=%d\n",self2.name, self2.score);
}
else
{
err_sys("fread error\n");
}
exit(0);
}

格式化I/O

格式化输出

1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>
int printf(const char *restrict format,...);
int fprintf(FILE *restrict fp, const char *restrict format,...);
int dprintf(int fd,const char *restrict format,...);
//若成功,返回输出字符数,若失败,返回负值

int sprintf(char *restrict buf,const char *restrict format,...);
//若成功,返回存入数组的字符数,若编码错误,返回负值

int snprintf(char *restrict buf,size_t n,const char *restrict format,...);
//若缓冲区足够大,返回将要存入数组的字符数,若编码错误,返回负值.

sprintf将格式化的字符输出到数组buf中,会在数组的尾端加上一个null.sprintf函数可能导致缓冲区buf溢出.为了解决缓冲区溢出问题,引入了snprintf函数,在该函数中,缓冲区是一个显式参数,超过缓冲区长度的部分会被丢弃,与sprintf相同,返回值不包括结尾的null字节.

格式说明控制其余参数如何编写,以后又该如何显示.每个参数按照转换说明编写,转换说明以百分号%开始,除转换说明外,格式字符串的其他字符将按原样,不经任何修改被复制输出.一个转换说明有4个可选部分:

1
%[flags][fldwidth][precision][lenmodifier]convtype
标志 说明
(撇号)将整数按照千位分组字符
- 在字段内左对齐输出
+ 总是显示带符号转换的正负号
(空格) 如果第一个字符不是正负号,则在其前面加一个空格
# 指定另一中转换形式(例如,对于十六进制格式,加0x前缀)
0 添加前导0进行填充

fldwidth说明最小字段宽度.转换后参数若小于宽度,则多余字符使用空格填充.宽度是一个非负十进制数或*.

precision说明整型转换后最少输出数字位数,浮点数转换后小数点后的最少位数,字符串转换后最大字节数.精度是一个.,其后更随一个可选的非负十进制数或一个*.

lenmodifier说明参数长度:

长度修饰符 说明
hh 将相应参数按照signed或者unsigned char类型输出
h 将相应参数按照signed或者unsigned short类型输出
l 将相应参数按照signed或者unsigned long类型输出
ll 将相应参数按照signed或者unsigned long long类型输出
j intmax_t或uintmax_t
z size_t
t ptrdiff_t
L long double

convtype不是可选的,它控制如何解释参数.

转换类型 说明
d/i 有符号十进制
o 无符号八进制
u 无符号十进制
x/X 无符号十六进制
f/F 双精度浮点数
e/E 指数格式双精度浮点数
g/G 根据转换后的值解释为f/F/e/E
a/A 十六进制指数格式双精度浮点数
c 字符(若带长度修饰符1,为宽字符)
s 字符串(若带长度修饰符1,为宽字符)
p 指向void的指针
n 到目前为止,次printf调用输出的子符的数目将被写到指针说指向的带符号整型中
% 一个%字符
C 宽字符,等价于1c
S 宽字符串,等价于1s

格式化输入

1
2
3
4
5
#include<stdio.h>
int scanf(const char *restrict format,...);
int fscanf(FILE *restrict fp,const char *restrict format,...);
int sscanf(const char *restrict buf,const char *restrict format,...);
//赋值的输入项数,若错误或者在任一转换前已经到达文件末尾则返回EOF

scanf族用于分析输入字符串,并将字符序列转换为指定类型变量.在格式之后包含了变量的地址(因此使用&a),用转换结果对这些变量赋值.

格式说明控制如何转换参数,以便对他们赋值.转换说明以%开始.除转换说明和空格外,格式字符中的其他字符必须与输入一致.若存在一个字符不匹配,则停止后续处理.

一个转换说明有三个可选部分:

1
%[*][fldwidth][m][lenmodifier]convtype

可选的(*)是抑制转换,按照转换说明的其余部分对输入进行转换,但转换后的结果并不放到结果参数中.

可选项m是赋值分配符.可以用于%C,%S以及%[转换符,迫使内存缓冲区分配空间以接纳字符串.此时,相关参数必须是指针地址,分配的缓冲区地址必须赋值给该指针.如果调用成功,该缓冲区域不再使用时,由用户负责调用free来释放该缓冲区.

转换类型 说明
d 符号十进制
i 有符号十进制
O 无符号八进制
u 无符号十进制
x/X 无符号十六进制
a/A/e/E/f/F/g/G 浮点数
c 字符(若带长度修饰符1,为宽字符)
s 字符串(若带长度修饰符1,为宽字符)
[ 匹配列出的字符序列,以]终止
[^ 匹配除列出了来的字符以外的所有字符,以]终止
p 指向void的指针
n 将到目前为止该函数调用读取的字符数写入到指针所指向的无符号整型中
% 一个%符号
C 宽字符,等效与1c
S 宽字符,等效于ls

实现细节

每个标准I/O流都有一个与其相关的文件描述符,可以对一个流调用fileno函数来获得其描述符:

1
2
#include<stdio.h>
int fileno(FILE *fp);

第六章 系统数据文件和信息

口令文件

UNIX系统口令文件包含了下列的个字段(linux不包含最后三个字段),这些字段包含在中定义的passwd结构中.

说明 struct passwd成员
用户名 char *pw_name
加密口令 char *pw_passwd
数值用户ID uid_t pw_uid
数值组ID gid_t pw_gid
注释字段 char *pw_gecos
初始工作目录 char *pw_dir
初始shell(用户程序) char *pw_shell
用户访问类 char *pw_class
下次更改口令时间 time_t pw_change
账户有效期时间 time_t pw_expire

口令文件是/ect/passwd.每一行包含上述各字段,字段之间用冒号分隔.

关于登录项,需要注意:

  1. 通常有一个用户名为root的登录项,其用户ID是0(超级用户).
  2. 加密口令字段包含了一个占位符.
  3. shell字段包含了一个可执行程序名,它被用来作为该用户的登录shell.若为空,使用系统默认值,一般是/bin/shell.
  4. 为了阻止一个特定用户登录系统.可以在初始shell中使用/dev/null或者/bin/false或在/bin/true禁止一个账户.
  5. 使用nobody用户名的一个目的是,使任何人都能够登录至系统,但其用户ID(65534)和用户组ID(65534)不提供任何权限,只可以访问人人都可以读写的文件.

下面的两个函数可以获得口令文件项:

1
2
3
#include<pwd.h>
struct passwd *getpwuid(uid_t uid);
struct passwd *getpwnam(const char *name);

getpwuid函数由ls程序使用,它将i节点中的数字用户ID映射为用户登录名.在键入登录名时,getpwnam函数由login程序调用.passwd结构通常是函数内部的静态变量,只要调用任一相关函数,其内容就会被重写.

当程序想要查看整个口令文件时,可以使用下列3个函数:

1
2
3
4
5
6
#include<pwd.h>
struct passwd *getpwent(void);
//若成功,返回指针,若错误或者到达文件末尾返回NULL

void setpwent(void);
void endpwent(void);

每次调用getpwend时,其返回口令文件的下一个记录项.setpwent用来将getpwent()的读写地址指向口令文件的开头,endpwent则关闭这些文件.在使用getpwent后一定要使用endpwent关闭这些文件.

getpwnam的一个实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include<stdio.h>
#include<pwd.h>
#include<stddef.h>
#include<string.h>
passwd *getpwnam(const char *name)
{
passwd *ptr;
setpwent(); //确保文件以及被关闭
while((ptr=getpwent())!=NULL)
{
if(strcmp(name,ptr->pw_name)==0)
{
break;
}
}
endpwent();
return ptr;
}
int main(int argc, char *argv[])
{
passwd *ptr = getpwnam(argv[1]);
if(ptr!=NULL)
{
printf("name:%-10s; passwd:%-10s; uid:%5d, gid=%-5d; gecos=%-20s; dir=%-10s; shell=%-10s\n",ptr->pw_name
,ptr->pw_passwd,ptr->pw_uid,ptr->pw_gid,ptr->pw_gecos,ptr->pw_dir,ptr->pw_shell);
}
return 0;
}

阴影口令

加密口令是经过单向加密算法处理过的用户副本.因为此算法是单向的,所以不能从加密口令猜测到原来的口令.

为了使一般用户无法获得加密口令,系统将加密口令放在另一个通常称为阴影口令的文件中,该文件至少要包含用户名与加密口令.与该口令有关的信息也可以放在该文件中:

说明 struct spwd成员
用户登录名 char *sp_name
加密口令 char *sp_pwdp
上次更改口令以来经过时间 int sp_lstchg
经多少天后允许更改 int sp_min
要求更改剩余天数 int sp_max
超期警告天数 int sp_warn
账户不活动之前剩余天数 int sp_inact
账户超期天数 int sp_expire
保留 unsigned int sp_flag

阴影口令文件不是一般用户可以读取的.仅少数几个程序需要访问加密口令,如login和passwd,这些用户常常设置用户ID为root.与访问口令文件相似,存在访问阴影口令文件的一组函数:

1
2
3
4
5
6
7
#include<shadow.h>
struct spwd *getspnam(const char *name);
struct spwq *getspent(void);
//成功返回指针,失败返回NULL

void setspent(void);
void endspent(void);

组文件

UNIX组文件包含了下面所列字段,这些字段包含在中所定义的group中:

说明 struct group成员
组名 char *gr_name
加密口令 char *gr_passwd
数值组ID int gr_gid
指向个用户名指针的数值 char **gr_mem

下列两个函数可以查看组名或组ID:

1
2
3
#include<grp.h>
struct group *getgrgid(gid_t gid);
struct group *getgrnma(const char *name);

与口令文件类似,这里的group也是静态变量的指针.

如果需要搜索整个组文件:

1
2
3
4
#include<grp.h>
struct group *getgrent(void);
void setgrent(void);
void endgrent(void);

附属组ID

我们不仅可以属于口令文件记录项中的组ID所对应的组,也可以属于多至16个另外的组.文件访问权限被修改为:不仅将进程有效ID与文件的组ID进行比较,而且也将所以附属组ID与文件的组ID进行比较.使用附属组ID的一个好处是不用经常更改组.

为了获取和设置附属组ID,提供了下面三个函数:

1
2
3
4
5
6
7
8
9
10
11
12
#include<unistd.h>
int getgroups(int gidsetsize,gid_t grouplist[]);
//成功返回附属组ID数量,出错返回-1

#include<grp.h> //on linux
#include<unistd.h> //on freebsd, mac os x, solaris
int setgroups(int ngroups,const gid_t grouplist[]);

#include<grp.h> //on linux
#include<unistd.h> //on freebsd, mac os x, solaris
int initgroups(const char *username,gid_t basegid);
//两个函数成功返回0,失败返回-1

getgroup将进程所属用户的各附属组ID填写到数组grouplist中,填入该数组的附属组ID最多gidsetsize个,实际填写的数量由函数返回.

setgroups可由超级用户调用以便为调用进程设置附属组ID表,grouplist是组ID数组,ngroups说明数组中元素个数.

通常只有initgroups函数调用setgroups,initgroups读整个组文件,然后对username确定其组的成员关系,然后调用setgroups,以便为该用户初始化附属组ID表.

其他数据文件

一般情况下,对每个数据文件至少有三个函数:

(1) get函数:读下一条记录,如果需要还会打开该文件,一般返回静态存储类结构的指针.

(2) set函数:打开对应数据文件,然后反绕该文件.

(3) end函数:关闭相关数据文件.

另外,如果数据文件支持某种形式的键搜索,则也提供搜索具有指定键的记录的例程.

下面列出一些常用的数据文件

说明 数据文件 头文件 结构 附加键搜索函数
口令 /etc/passwd passwd getpwnam, getpwuid
/etc/group group getgrnam, getgrgid
阴影 /etc/shadow spwd getspnam
主机 /etc/hosts hostent getnameinfo, getaddrinfo
网络 /etc/networks netent getnetbyname, getnetbyaddr
协议 /etc/protocols protoent Getprotobyname, getprotobynumber
服务 /etc/services servent getservbyname, getservbyport

登录账户记录

UNIX下提供了两个数据文件:utmp文件记录当前登录到系统的各个用户;wtmp文件跟踪各个登录和注销事件.每次写入的是包含下列结构的一个二进制记录:

1
2
3
4
5
struct utmp{
char ut_line[8];
char ut_name[8];
long ut_time;
};

登录时,login程序填写此类型数据结构,然后将其写入到utml文件,同时也添加到wtmp文件.注销时,init进程将utmp文件中相应记录删除,并将一个新记录添加到wtmp文件中.

系统标识

1
2
#include<sys/utsname.h>
int uname(struct utsname *name);

uname函数返回与主机和操作系统相关的信息.该函数向其中传递一个utsname地址,该函数会填充结构内容.结构包含如下信息:

1
2
3
4
5
6
7
struct{
char sysname[];
char nodename[];
char release[];
char version[];
char matchine[];
};

获取主机名:

1
2
#include<unistd.h>
int gethostname(char *name,int namelen);

该名字通常就是TCP/IP网络上主机的名字.

时间和日期例程

UNIX内核提供的基本时间服务是计算自协调世界时(UTC)公元1970年1月1号00:00:00这一特定时间以来经过的秒数.这种秒数是以数据类型time_t表示的(第三章),我们称之为日历时间.日历时间包含时间和日期.UNIX特点是:(1)以协调统一时间而非本地时间计时;(2)可自动进行转换;(3)将时间和日期作为一个量值保存.

time函数返回当前时间和日期:

1
2
#include<time.h>
time_t time(time_t *calptr);

POSXI.1的实时扩展增加了对多个系统时钟的支持.时钟通过clockid_t类型进行标识.

标识符 选项 说明
CLOCK_REALTIME 实时系统时间
CLOCK_MONTONIC _POSIX_MONOTONIC_CLOCK 不带负跳数的实时系统时间
CLOCK_PROCESS_CPUTIME_ID _POSIX_CPUTIME 调用进程的CPU时间
CLOCK_THREAD_CPUTIME_ID _POSIX_THREAD_CPUTIME 调用线程的CPU时间

clock_gettime函数可用来获取指定时钟时间,返回timespec结构(第四章),其把时间表示为秒和纳秒:

1
2
#include<sys/time.h>
int clock_gettime(clockid_t clock_id,struct timespec *tsp);

当时钟ID设置为CLOCK_REALTIME时,clock_gettime函数提供了与time函数类似的功能,不过clock_gettime可能比time函数的精度高.

1
2
#include<sys/time.h>
int clock_getres(clockid_t clock_id,struct timespec *tsp);

clock_getres函数将tsp指向的timespec结构初始化为与clock_id对应的时钟精度.

如果需要对特定的时钟设置时间,可以调用clock_settime函数:

1
2
#include<sys/time.h>
int clock_settime(clockid_t clock_id, const struct timespec *tsp);

下图展示了各种时间函数之间的关系:

time

图中虚线表示的三个函数localtime,mktime和strftime都受到环境变量TZ的影响.两个函数localtime和gmtime将日历时间转换成分解的时间,并将这些存放在一个tm结构中:

1
2
3
4
5
6
7
8
9
10
11
struct tm{
int tm_sec; //秒:[0,60]
int tm_min;//分钟:[0,59]
int tm_hour;//小时:[0,23]
int tm_mday;//一个月的某一天:[1,31]
int tm_mon;//月:[0-11]
int tm_year;//年,从1970年开始到现在
int tw_wday;//一周的某一天[0,6]
int tw_yday;//一年的某一天[0,365]
int tm_isdst;//夏令时标志
};

从日历时间获得分解时间:

1
2
3
4
#incude<time.h>
struct tm *gmtime(const time_t *calptr);
struct tm *localtime(const time_t *calptr);
//成功返回指针,出错返回NULL

localtime和gmtime的区别是,localtime将日历转为本地时间,而gmtime将日历时间转换为协调统一时间.

从分解时间转换的日历时间:

1
2
3
#include<time.h>
time_t mktime(struct tm *tmptr);
//成功返回日历时间,出错返回-1

打印时间:

1
2
3
4
#include<time.h>
size_t strftime(char *restrict buf,size_t maxsize,const char *restrict format,const struct tm *tmptr);
size_t strftime_l(char *restrict buf,size_t maxsize,const char *restrict format,const struct tm *restrict tmptr,locale_t local);
//若有空间则返回存入数组字符数,否则返回0

strftime_l将区域指定为参数,除此之外两个函数完全一致.strftime使用环境变量TZ指定区域.

format参数控制了时间值的格式.形式是在一个百分号后更随一个特定字符,其他字符原样输出,不存在字段宽度修饰符.

格式 说明 实例
%a 缩写的周日名 Thu
%A 周日名 Thursday
%b 缩写的月名 Jan
%B 月名 January
%c 日期和时间 Thu Jan 19 21:24:52 2012
%C 年/100(00-99) 20
%d 月日(01-31) 19
%D 日期(MM/DD/YY) 01/19/12
%e 月日(一位数字前加空格)(1-31) 21
%F ISO 8601日期格式(YYYY-MM-DD) 2012-01-09
%g ISO 8601基于周的年的最后两位数(00-99) 12
%G ISO 8601基于周的年 2012
%h %b相同 Jan
%H 小时(24)(00-23) 21
%I 小时(12)(00-11) 09
%j 年日(001-366) 019
%m 月(01-12) 01
%M 分(01-59) 23
%n 换行符
%p AM/PM PM
%r 本地时间(12) 09:24:52 PM
%R "%H:%M"相同 21:24
%S 秒[00-60] 52
%t 水平制表符
%T "%H:%M:%S"相同` 21:24:52
%u ISO 8601周几(1-7) 4
%U 星期日周数(00-53) 03
%V ISO 周数(01-53) 03
%w 周几(0-6) 03
%W 星期一周数(00-53) 03
%x 本地日期 01/19/12
%X 本地时间 21:24:52
%y 年的最后两位数(00-99) 12
%Y 2012
%z ISO 8601格式的UTC偏移量 -0500
%Z 时区名 EST
%% 翻译为一个% %

打印时间例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include<time.h>
#include<stdio.h>
#include<stdlib.h>
int main(void)
{
time_t t;
struct tm *tmp;
char buf1[16];
char buf2[64];
time(&t);
tmp = localtime(&t);
if(strftime(buf1,16,"time and date:%r, %a %b %d, %Y", tmp)==0)
{
printf("buffer length 16 is too small\n");
}
else
{
printf("%s\n",buf1);
}
if(strftime(buf2,64,"time and date:%r, %a %b %d, %Y", tmp)==0)
{
printf("buffer length 64 is too small\n");
}
else
{
printf("%s\n",buf2);
}
exit(0);
}

strptime函数是strftime的反过来的版本,把字符串时间转换为分解时间:

1
2
#include<time.h>
char *strptime(const char *restrict buf, const char *restrict format, struct tm *restrict tmptr);

格式说明符与上述类似.

第七章 进程环境

main函数

1
int main(int argc,int *argv[]);

内核执行C程序时(使用一个exec函数),在调用main前先调用一个特殊的启动例程.可执行程序文件将此启动例程指定为程序的起始地址.启动例程从内核获取环境变量值和命令行参数.

进程终止

共有八种进程终止方式,其中五种正常终止:

(1)从main函数返回;

(2)调用exit;

(3)调用_exit_Exit

(4)最后一个线程从其启动例程返回;

(5)从最后一个线程调用pthread_exit;

三种异常终止:

(6)调用abort;

(7)接到一个信号;

(8)最后一个线程对取消请求做出响应.

启动例程一般是从main函数返回后立即调用exit函数,大概是:

1
exit(main(argc,argv));

1. 退出函数

3个函数用于正常终止一个程序:

1
2
3
4
5
#include<stdlib.h>
void exit(int status);
void _Exit(int status);
#include<unistd.h>
void _exit(int status);

其中_Exit_exit立即进入内核,exit则先执行一些清理,在返回内核.exit总是执行I/O库的清理关闭操作.

3个函数都带一个整型参数,称为终止状态.如果(a)调用这些函数时不带终止状态;(b)main执行了一个无返回的return语句;(c)main未申明返回类型为整型,则进程终止状态是未定义的.但若main返回类型为整型,并且main执行到最后一句返回(隐式返回也可以),那么进程终止状态是0.

main函数调用exit(0)return 0是等价的.

打印终止状态(程序执行之后):

1
$echo $?

2. 函数atexit

一个进程可以登录多至32个程序,这些函数将由exit自动调用,这些函数称为终止处理程序,并调用atexit函数来登记这些函数:

1
2
3
#include<stdlib.h>
int atexit(void (*func)(void));
// 成功返回0,否则非0

参数为函数地址,调用函数时无需传递任何参数,也不期待存在返回值.exit调用这些函数的顺序与他们登记的顺序相反,同一个函数如果登录多次也会被执行多次.下图展示了一个C程序如何启动:

c程序

注意:内核使程序执行的唯一方法是调用一个`exec`函数.程序自愿终止的唯一方法是显示或隐式地(通过调用`exit`)调用`_exit`或`_Exit`.进程也可以非自愿的由一个信号使其终止.

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include<stdlib.h>
#include"apue.h"
static void myexit1(void);
static void myexit2(void);
int main(void)
{
if(atexit(myexit2)!=0)
{
err_sys("can't register myexit2");
}
if(atexit(myexit1)!=0)
{
err_sys("can't register myexit1");
}
if(atexit(myexit1)!=0)
{
err_sys("can't register myexit1");
}
printf("main is done!\n");
return 0;
}

static void myexit1()
{
printf("first exit handler\n");
}

static void myexit2()
{
printf("second exit handler\n");
}

执行结果:

1
2
3
4
5
6
main is done!
first exit handler
first exit handler
second exit handler

//myexit1和myexit2均是在函数返回时调用exit时才执行,执行顺序与添加顺序相反,加入多次会执行多次.

命令行参数

当执行一个程序时,调用exec的进程可以将命令行参数传递给该新进程.

环境表

每个程序都接收一张环境表.环境表也是一字符指针数组,其中每个指针包含一个以null为结尾的字符串的地址.全局变量environ包含了该指针数组的地址:

1
extern char **environ;

environ为环境指针,指针数组为环境表,其中各个指针指向的字符串为环境字符串.环境由name=value这样的字符组成,如下图:

环境表

C程序存储空间分布

C程序由下列几部分组成:

  1. 正文段.由CPU执行的机器指令.通常正文段是可共享的,在存储器中只需要一个副本,同时正文段是只读的,防止程序由于意外而修改其指令.
  2. 初始化数据段.通常称为数据段,包含了程序中明确地赋初值的变量,如C程序任意函数外申明int maxcount = 99;.
  3. 未初始化数据段,通常称为bss,在程序开始执行前,内核将此段中的数据初始化为0或空指针.如函数外的申明:long sum[1000].
  4. 栈.自动变量以及每次函数调用时保存的信息都存放在次段中.每次函数调用时,其返回地址以及调用者环境信息都放在栈中.最近被调用的函数在栈上为其自动变量和临时变量分配存储空间.递归函数调用自身时,就会使用一个新的栈帧,因此一次函数调用实例中的变量集不会影响另一次函数调用实例中的变量.
  5. 堆.通常在堆中进行动态内存分配.

存储空间分配

未初始化数据段的内容并不会存放在磁盘程序文件(可执行文件).内核在运行程序前将他们置0.需要存放在磁盘文件的只有正文段和初始化数据段.

size目录报告正文段,数据段和bss段的长度(字节),如:

1
2
3
$ size ./atexit.o
text data bss dec hex filename
4911 688 48 5647 160f ./atexit.o

第4列和第5列分别是以十进制和十六进制表示的三个文件总长度.

共享库

共享库使得可执行文件中不在需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种库例程的副本.程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相连接.这减少了每个可执行文件的长度,但增加了一些运行的开销,这种开销发生在第一次执行程序或第一次调用库函数.共享库的另一个优点是可以用库函数的新版本代替老版本而不用对使用该库的程序重新连接编辑.

例:

使用共享库进行编译:

1
2
3
4
5
6
$ g++ atexit.cpp -o atexit.o
$ ls -l atexit.o
-rwxrwxr-x 1 chst chst 13384 Sep 24 00:17 atexit.o
$ size atexit.o
text data bss dec hex filename
4911 688 48 5647 160f atexit.o

无共享库进行编译:

1
2
3
4
5
6
g++ -static atexit.cpp -o atexit.o //阻止使用共享库-static
$ ls -l atexit.o
-rwxrwxr-x 1 chst chst 849744 Sep 24 00:19 atexit.o
$ size atexit.o
text data bss dec hex filename
746297 21068 5984 773349 bcce5 atexit.o

可以明显看出,使用共享减少了大量空间.

存储空间分配

ISO C说明了三种用于存储空间分配的函数:

1
2
3
4
5
6
7
#include<stdlib.h>
void *malloc(size_t size);
void *calloc(size_t nobj,size_t size);
void *realloc(void *ptr,size_t newsize);
// 成功返回非空指针,否则返回NULL

void free(void *ptr);

malloc分配指定字节的存储区域,初始值不定.calloc为指定数量指定长度的对象分配存储空间,该空间的每一位(bit)都是0.realloc增加或减少以前分配器的长度,参数是newsize是改变后的长度而不是改变的长度.如果是增大空间,可能需要将以前分配的内容移到另一个更大的区域,以便在尾部提供增加的区域,新区域的初始值不定.

free释放ptr指向的存储空间.

大多数实现所分配的存储空间都比所要求的稍微大一些,额外的开销用来记录管理信息—分配块的长度,指向下一个个块的指针等.这意味着,如果超过一个已分配的尾端或者在已分配区起始位置之前进行写操作,则会改写另一块的管理信息,这种错误是灾难性的,但不会很快暴露出来,所以很难发现.

环境变量

环境字符串形式:

1
name=value

ISO C提供一个函数getenv来获取环境变量值:

1
2
3
#include<stdlib.h>
char *getenv(const char *name);
//返回指向name关联的value指针,如果未找到,返回NULL;

下面列出了环境变量内容:

变量 说明
COLUMNS 终端宽度
DATEMSK getdate模板文件路径名
HOME home起始目录
LANG 本地名
LC_ALL 本地名
LC_COLLATE 本地排序名
LC_CTYPE 本地字符分类名
LC_MESSAGES 本地消息名
LC_MONETART 本地货币编辑名
LC_NUMERIC 本地数字编辑名
LC_TIME 本地日期/时间格式名
LINES 终端高度
LOGNAME 登录名
MSGVERB fmtmsg处理的消息组成部分
NLSPATH 消息类模板序列
PATH 搜索可执行文件的路径前缀列表
PWD 当前工作路径的绝对路径名
SHELL 用户首选的shell名
TERM 终端类型
TMPDIR 在其中创建临时文件的目录路径名
TZ 时区信息

有时,我们也需要设置环境变量或者增加新的环境变量(我们能够影响的只是当前进程及其后生成的和调用的任何子进程的环境,但不影响父进程的环境),此时我们可以使用下面的函数:

1
2
3
4
5
6
7
#include<stdlib.h>
int putenv(char *str);
//成功返回0,否则非0

int setenv(const char *name, const char *value, int rewrite);
int unsetenv(const char *name);
//成功返回0,否则-1

putenv取形式为name=value的字符串,将其放到环境表中,如果name已经存在则先删除。

setenvname设置为value,如果环境中name已经存在,那么是否重写取决于rewrite

unsetenv删除name的定义,即使不存在name的定义也不会出错。

修改环境表是如何操作的?

环境表和环境字符串通常占用的是进程地址空间的顶部(见C程序存储空间分布图),此时删除一个是十分简单的,但是增加或者修改一个是相对复杂的。这是因为它不能够再向高地址(向上)扩展,同时也不能移动在它下面的各栈帧,所以也不能向低地址(向下)扩展。

(1)如果修改一个现有的name:

  1. 如果新的value长度不大于现在value长度,则只将新字符串复制到原字符串所在位置。
  2. 如果新的value长度大于原长度,则必须使用malloc为新字符串分配空间,然后将新字符串复制到该空间,接着使用环境表中针对name的指针指向新分配区。

(2)新增加一个name,必须调用malloc为name=value字符串分配空间,而后将字符串复制到该空间。

  1. 如果是第一次添加,则必须调用malloc为新的指针表分配空间。接着将原来的环境表分配到新分配区,并将name=value字符串的指针存放在该指针表的末尾,然后将一个空指针存放在其后。最后使environ指向新的指针表。此时,原来指针表位于栈顶之上,那么必须将次表移到堆中,但此时表中大多数指针仍指向栈顶的各name=value。
  2. 如果不是第一次增加,则只要调用realloc以分配比原空间多存放一个指针的空间,然后将指向新的name=value的指针放到末尾,后面接一个空指针。

函数setjmp和longjmp

C语言中goto不能跨越函数,而执行此类跳转是函数setjmp和longjmp。这两个函数用于很深层嵌套函数调用中出错是十分有效的。

考察下面的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include"apue.h"
#define TOK_ADD 5

void do_line(char *);
void cmd_add(void);
int get_token(void);

int main()
{
char line[MAXLINE];
while(fgets(line,MAXLINE,stdin)!=NULL)
{
do_line(line);
}
}

char *tok_ptr;

void do_line(char *ptr)
{
int cmd;
tok_ptr = ptr;
while((cmd=get_token())>0)
{
switch (cmd)
{
case TOK_ADD:
cmd_add();
break;
}
}
}

void cmd_add(void)
{
int token;
token = get_token();
// 接下来处理相对应的指令
}

int get_token(void)
{
//从tok_ptr*中获取下一条指令;
}

程序的基本骨架在读命令,确定命令类型,然后调用响应函数处理每一条指令。下图展示了调用到cmd_add之后栈的大致使用情况:

栈使用情况

自动变量存储在每个函数的栈帧中,数组line存储在main的栈帧中,cmd存储在do_line栈帧中,token在cmd_add栈帧中。

当发生一个非致命性错误时,例如,如果cmd_add函数发生一个错误,那么可能会先打印一个错误,然后忽略接下来的输入,返回main函数并读取下一行。如果出现在C函数的深层嵌套中,处理起来是十分麻烦的,我们不得不以检测返回值的形式逐层返回。

解决这种问题的一个方法是使用非局部goto—setjmp和longjmp函数。非局部是指,这不是普通的goto在一个函数中跳转,而是在栈上跳过若干调用帧,返回到当前函数调用路径上的某个函数上。

1
2
3
4
5
#include<setjmp.h>
int setjmp(jmp_buf env);
//若直接调用返回0,若从longjmp返回,则为非0

void longjmp(jmp_buf env,int val);

在希望返回到的位置调用setjmp。参数env的类型是一个特殊的jmp_buf。因为需要在另一个函数中引用env变量,通常将其定义为全局变量。

当检测到错误使用两个参数调用longjmp函数,第一个是setjmp的env,第二个是一个非0val,它将成为setjmp的返回值,可以用来判断出错的位置和类型。

利用setjmp和longjmp对之前的程序进行更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include"apue.h"
#include<setjmp.h>
#define TOK_ADD 5

jmp_buf jmpbuff;

void do_line(char *);
void cmd_add(void);
int get_token(void);

int main()
{
char line[MAXLINE];
if(setjmp(jmpbuff)!=0)
{
printf("error!\n");
}
while(fgets(line,MAXLINE,stdin)!=NULL)
{
do_line(line);
}
}

char *tok_ptr;

void do_line(char *ptr)
{
int cmd;
tok_ptr = ptr;
while((cmd=get_token())>0)
{
switch (cmd)
{
case TOK_ADD:
cmd_add();
break;
}
}
}

void cmd_add(void)
{
int token;
token = get_token();
if(token<0)
{
longjmp(jmpbuff,1);
}
// 接下来处理相对应的指令
}

int get_token(void)
{
//从tok_ptr*中获取下一条指令;
}

执行main函数时,调用setjmp,它将所需的信息记入变量jmpbuff中并返回0,。随后调用do_line,它又调用cmd_add,当出现错误时,调用longjmp后会丢弃cmd_add和do_line的栈帧,同时造成main函数中setjmp返回1。调用后的栈帧为:

longjmp

自动变量、寄存器变量和易失变量

调用longjmp后栈帧如上所述,但此时main函数中自动变量、寄存器变量和易失变量的状态又该如何?是否能够恢复到以前调用setjmp时的状态(回滚),或者保持不变。回答是不确定的。大多数都不回滚,但所以实现都声称不确定。当有一个自动变量又不想让其回滚,可以定义为具有volatile属性。申明为全局变量或静态变量的值在执行完longjmp不回滚。

下面通过实例说明自动变量、全局变量、寄存器变量、静态变量和易失变量的不同情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include"apue.h"
#include<setjmp.h>
static void f1(int, int, int,int);
static void f2(void);

static jmp_buf jmpbuffer;
static int globval;

int main(void)
{
int autoval;
register int regival;//寄存器变量
volatile int volaval;//易失变量
static int statval;
globval =1; autoval = 2; regival =3;volaval=4;statval=5;
if(setjmp(jmpbuffer)!=0)
{
printf("after longjmp:\n");
printf("global=%d, autoval=%d, regival=%d, volaval=%d, statval=%d\n",
globval, autoval,regival,volaval,statval);
exit(0);
}
globval =95; autoval = 96; regival =97;volaval=98;statval=99;
f1(autoval,regival,volaval,statval);
exit(0);
}

static void f1(int i, int j, int k, int l)
{
printf("f1():\n");
printf("global=%d, autoval=%d, regival=%d, volaval=%d, statval=%d\n",
globval, i,j,k,l);
f2();
}

static void f2()
{
longjmp(jmpbuffer,1);
}

执行:

1
2
3
4
5
6
$ g++ jmpval.cpp -lapue
$ ./a.out
f1():
global=95, autoval=96, regival=97, volaval=98, statval=99
after longjmp:
global=95, autoval=96, regival=3, volaval=98, statval=99

自动变量的潜在问题

自动变量存在一个潜在出错情况,基本规则是申明自动变量的函数已经返回后,不能再引用这些自动变量。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FILE *open_data(void)
{
FILE *fp;
char databuf[BUFSIZ];
if((fp=fopen("datafile","r"))==NULL)
{
return NULL;
}
if(setvbuf(fp, databuf,_IOLBF,BUFSIZ)!=0)
{
return NULL;
}
return fp;
}

当open_data返回时,他在栈上使用的空间将由下一个被调用函数的栈帧使用。但标准I/O还将使用这部分存储空间作为缓冲区(databuf)。这就会产生冲突和混乱,为了解决这个问题,应该在全局存储空间静态地(如static或extern)或者动态的(malloc)为数组databuf分配空间。

函数getrlimit和setrlimit

每一个进程都存在一组资源限制,其中一些可以使用getrlimit和setrlimit函数来查询和更改:

1
2
3
4
#include<stdio.h>
int getlimit(int resure,struct rlimit *rlptr);
int setrlimit(int resurce,const struct rlimit *rlptr);
//成功返回0,否则返回非0

进程的资源环境通常由0进程来建立,然后由后续进程继承。函数调用制定一个资源以及一个指向rlimit结构的指针:

1
2
3
4
struct rlimit{
rlimit_t rlim_cur; // soft limit:current limit
rlimit_t rlim_max; //hard limit:maximun value for rlim_cur
};

更改资源限制时需要遵守下列三条限制:

  1. 任何一个进程都可以将软限制调整到不大于硬限制。
  2. 任何一个进程都可以降低硬限制,但必须大于或等于软限制,这种降低对于普通用户而言是不可逆的。
  3. 只有超级进程可以提高硬限制值。

常量RLIM_INFINITY指定了一个无限量的限制。

限制 含义
RLIMIT_AS 进程可以使用的存储空间最大的长度(字节)。影响到sbrk和mmap函数。
RLIMIT_CORE core文件的最大长度,0表示阻止生成core文件。
RLIMIT_CPU CPU时间的最大秒数,当超过此限制时,向该进程发送SIGXCPU信号。
RLIMIT_DATA 数据段的最大字节长度,是初始化数据、非初始以及堆的总和。
RLIMMIT_FSIZE 可以创建的文件的最大长度,超过此限制将会向进程发送信号SIGFSZ信号。
RLIMIT_MEMLOCK 一个进程可以使用mlock能够锁定在存储空间的最大字节长度。
RLIMIT_MSGQUEUE 进程为POSIX消息队列可分配的最大存储字节数。
RLIMIT_NICE 为了影响进程的调度优先级,nice值能够设置的最大限制。
RLIMIT_NPTS 用户可以同时打开的伪终端的最大限制。
RLIMIT_NOFILE 每个进程可以打开的最多的文件数。
RLIMIT_NPROC 每个实际用户ID可拥有的最大子进程数量。
RLIMIT_RSS 最大驻内存集字节长度,如果可用的物理存储器非常少,则内核将从进程处取回超过RSS的部分。
PLIMIT_SBSIZE 在任一给定时刻,一个用户可以占用的套接字的缓冲区的最大长度(字节)(linux上不存在)
RLIMIT_SIGPENDING 一个进程可排队的信号的最大数量。
RLIMMIT_STACK 栈的最大字节数。
RLIMIT_SWAP 用户可消耗的交换空间最大字节数。
RLIMIT_VMEM RLIMIT_AS相同。

获取限制代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#include<sys/resource.h>
#include"apue.h"

#define doit(name) pr_limits(#name,name)

static void pr_limits(char *,int);

int main(void)
{
#ifdef RLIMIT_AS
doit(RLIMIT_AS);
#endif

doit(RLIMIT_CORE);
doit(RLIMIT_CPU);
doit(RLIMIT_DATA);
doit(RLIMIT_FSIZE);

#ifdef RLIMIT_MEMLOCK
doit(RLIMIT_MEMLOCK);
#endif

#ifdef RLIMIT_MSGQUEUE
doit(RLIMIT_MSGQUEUE);
#endif

#ifdef RLIMIT_NICE
doit(RLIMIT_NICE);
#endif

#ifdef RLIMIT_NOFILE
doit(RLIMIT_NOFILE);
#endif

#ifdef RLIMIT_NPROC
doit(RLIMIT_NPROC);
#endif

#ifdef RLIMIT_NPTS
doit(RLIMIT_NPTS);
#endif

#ifdef RLIMIT_RSS
doit(RLIMIT_RSS);
#endif

#ifdef RLIMIT_SBSIZE
doit(RLIMIT_SBSIZE);
#endif

#ifdef RLIMIT_SIGPENDING
doit(RLIMIT_SIGPENDING);
#endif

#ifdef RLIMIT_STACK
doit(RLIMIT_STACK);
#endif

#ifdef RLIMIT_SWAP
doit(RLIMIT_SWAP);
#endif

#ifdef RLMIT_VMEM
doit(RLMIT_VMEM);
#endif
exit(0);
}

static void pr_limits(char *name, int resource)
{
rlimit limit;
unsigned long long lim;
if(getrlimit(resource,&limit)<0)
{
err_sys("getrlimit error for %s", name);
}
printf("%-14s ",name);
if(limit.rlim_cur == RLIM_INFINITY)
{
printf("(infinite) ");
}
else{
lim = limit.rlim_cur;
printf("%10lld ",lim);
}
if(limit.rlim_max == RLIM_INFINITY)
{
printf("(infinite) ");
}
else{
lim = limit.rlim_max;
printf("%10lld ",lim);
}
putchar((int)'\n');
}

doit中使用了ISO C的字符串创建算符(#),以便为每个资源名产生字符串值:

1
2
3
doit(RLIMIT_CORE);
//被C预处理为:
pr_limits("RLIMIT_CORE", RLIMIT_CORE);

第八章 进程控制

进程标识

每个进程存在一个非负整型表示的唯一进程ID。由于唯一性,常用来作为其他标识符的一部分以保证其唯一性。大多数UNIX实现延迟复用,使得新建进程的ID不同于最近终止进程所有的ID。

ID为0的进程通常是调度进程,常常被称为交换进程,该进程是内核的一部分并不执行磁盘上的任何程序。ID为1的进程通常是init进程,在自举过程结束时由内核调用。此进程负责在自举后启动一个UNIX系统。init进程绝对不会终止。

除了进程ID,进程还有其他标识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#includ<unsid.h>
pid_t getpid(void);
// 函数调用进程的进程ID

pid_t getppid(void);
//调用进程的父进程ID

pid_t getuid(void);
//调用进程的实际用ID

pid_t geteuid(void);
//调用进程的有效用户ID

pid_t getgid(void);
//调用进程的实际组ID

pid_t getegid(void);
//调用进程的有效组ID

函数fork

一个现有进程调用fork进程创建一个新进程:

1
2
3
#include<unistd.h>
pid_t fork(void);
//子进程返回0,父进程返回子进程的ID,出错返回-1

子进程是父进程的副本,子进程获得父进程数据空间、堆和栈的副本。这是子进程拥有的副本,与父进程并不共享这些存储空间部分。父进程和子进程共享正文段。

由于fork后经常更随着exec,所以现在很多实现并不执行一个父进程的数据段、堆和栈的完全副本,而是采用写时复制的策略。即这些区域子进程与父进程共享,内核将其访问权限更改为只读,当子进程或者父进程要试图修改这些区域时,内核才对要修改的区域那块内存赋值一个副本,通常是虚拟存储系统中的一页。

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include"apue.h"
int globvar = 6;
char buf[] = "a write to stdout\n";

int main(void)
{
int var;
pid_t pid;
var = 88;
if(write(STDOUT_FILENO,buf,sizeof(buf)-1)!=sizeof(buf)-1)
{
err_sys("write error\n");
}
printf("before fork\n");
if((pid = fork())<0)
{
err_sys("fork error\n");
}
else
{
if(pid == 0)
{
globvar++;
var++;
}
else
{
sleep(2);
}

}
printf("PID = %ld, glob = %d, var = %d\n", (long)getpid(), globvar, var);
exit(0);
}

这里使用两种不同的运行方式,将会获得两种不同输出:

1
2
3
4
5
6
7
8
9
10
11
12
$./fork.o
a write to stdout
before fork
PID = 14487, glob = 7, var = 89
PID = 14486, glob = 6, var = 88
$ ./fork.o > tem.txt
$ cat tem.txt
a write to stdout
before fork
PID = 14491, glob = 7, var = 89
before fork
PID = 14489, glob = 6, var = 88

fork之后是父进程先执行还是子进程先执行是不确定的。sizeof计算字符串包含的终止null,因此需要减一(strlen不包含)。对于strlen来说,每次执行就调用响应函数,而对于sizeof来说,因为缓冲区已用已知字符串进行初始化,其长度是固定的,因此sizeof是编译时计算缓冲区长度。

对于两种不同运行方式输出不同,这是由于:对于连接到终端的标准输出来说,缓冲方式为行缓冲,此时在调用fork之前,缓冲区已近被清空,此时调用fork,子进程缓冲区也是空的,因此只会输出一次(在父进程)。但是在非连接到终端的标准输出来说,采用的是全缓冲,此时在调用fork之前,父进程的缓冲区并未被清空(未输出),调用fork后,子进程获得父进程缓冲区的一份拷贝,最终两个进程输出时都会打印(“before fork”)。

父进程和子进程每个相同的打开的文件描述符共享一个文件表项:

文件描述符

父进程和子进程共享同一个文件偏移量。

fork之后处理文件描述符有下列两种情况:

  1. 父进程等待子进程完成。此时父进程无需对其文件描述符进行任何操作。子进程处理完成后,它进行过读写的共享描述符的偏移量以及做了相应更新。
  2. 父进程和子进程执行不同的代码段。此时,在fork之后子进程与父进程各自关闭不用的文件描述符,这样就不会干扰对方使用的文件描述符。

子进程继承于父进程的内容:

  1. 实际用户ID,实际组ID,有效用户ID,有效组ID。
  2. 附属组ID。
  3. 进程组ID。
  4. 会话ID。
  5. 控制终端。
  6. 设置用户ID标志和设置组ID标志。
  7. 当前工作目录。
  8. 根目录。
  9. 文件模式创建屏蔽字。
  10. 信号屏蔽和安排。
  11. 对任一打开文件描述符的执行时关闭(close-on-exce)。
  12. 环境。
  13. 连接的共享存储字段。
  14. 存储映射。
  15. 资源限制。

父进程和子进程的区别:

  1. fork返回值。
  2. 进程ID。
  3. 父进程ID不同。
  4. 子进程的tms_utime,tms_stime,tms_cutime和tms_ustime被设置为0。
  5. 子进程不继承父进程设置的文件锁。
  6. 子进程未处理的闹钟被清除。
  7. 子进程的未处理信号集设置为空集。

fork有以下两种用法:

  1. 一个进程希望复制自己,使父进程与子进程执行不同的代码段,这在网络服务进程中是最常见的。
  2. 一个进程要执行一个不同的程序。这对shell来说是常见的。

函数exit

进程存在八种终止方式。其中五种正常终止:

  1. main函数中执行return语句,这等效于调用exit。
  2. 调用exit函数。包括调用终止处理程序(atexit登记)。因为ISO C并不处理文件描述符、多进程以及作业控制,所以这一定义对于UNIX是不完整的。
  3. 调用_exit_Exit函数。_Exit函数为进程提供了一种不用运行终止处理程序或者信号处理程序而终止的方法。_exit_Exit是同义的。
  4. 进程的最后一个线程在其启动例程中执行return语句。该线程的返回值不作为进程的返回值。
  5. 进程的最后一个线程调用pthread_exit函数。

三种异常终止:

  1. 调用abort。它产生SIGABRT信号,其为下一中情况的特例。
  2. 当进程收到某些信号时。信号可由进程自身(如调用abort)、其他进程或者内核产生。
  3. 最后一个线程对“取消”请求作出相应。

不管进程如何终止,最终都会执行内核中的同一段代码为相应进程关闭所打开的文件描述符。

对任一种终止情况,我们都希望进程能够通知父进程其是如何终止的。在任意一张情况下,都可以使用waitwaitpid函数来获得其终止信息。

“退出状态”和“终止状态”的区别:在最后调用_exit时,内核将退出状态转换为终止状态。如果子进程正常终止,则父进程可以获得退出状态,否则只能获得终止状态。

注意:对于父进程终止的所以进程,他们的父进程都转换为init进程。我们称这些进程被init收养。操作方式为:当一个进程终止时,内核检查所有活动进程,以判断其父进程是否为终止的进程,如果是则将其父进程ID更改为1。这样能够保证每个进程都存在父进程。被init收养的进程将会被调用wait函数处理。

内核为每一个终止子进程保存了一定量的信息,所以当终止进程的父进程调用waitwaitpid时,可以获得这些信息。这些信息者少包含进程ID,该进程的终止状态以及该进程使用的CPU时间总量。内核可以关闭其所打开文件和释放终止进程所使用的存储器。一个已近终止、但其父进程未对其进行善后处理(获取终止进程相关信息、释放它(信息)所占用的资源)的进程称为僵死进程。ps命令将僵死进程打印为Z。

函数wait和waitpid

当一个进程终止时,内核就会向其进程发送SIGCHLD信号。父进程可以选择忽略该信号或者提供一个该信号发生时即被调用执行的函数(信号处理程序)。系统默认忽略。当调用wait或者waitpid时情况:

  1. 如果其所以子进程都还在运行,则阻塞。
  2. 如果一个子进程已经终止,正在等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
  3. 如果不存在任何子进程,则立即出错返回。
1
2
3
4
5
6
#include<sys/wait.h>
pid_t wait(int *statloc);

pid_t waitpid(pid_t pid, int *statloc, int options);

//成功返回子进程ID,否则返回0

函数区别为:

  1. 在一个子进程终止前,wait使调用者堵塞,而waitpid存在选项使调用者不堵塞。
  2. waitpid并不等待在其调用之后的第一个终止进程,它有若干选项,可以控制等待的进程。

statloc是一个整型指针。如果statloc不是空,则将进程终止状态放在其所指向的整型中。通过宏来判断终止状态:

说明
WIFEXITED(status) 若为正常终止进程返回状态,则为真。对于这种情况可执行WEXITSTATUS(status)获取子进程传递给exit_exit参数的低八位。
WIFSIGNALED(status) 若为异常终止子进程的返回状态,则为真(接到一个不捕捉的信号)。可执行WTERMSIG(status),获得子进程终止的信号编号。有些实现存在WCOREDUMO(status),可通过次宏来判断是否生成了终止进程的core文件。
WIFSTOPPED(status) 若为当前暂停的子进程返回的状态,则为真。此时可执行WSTOPSIG(status)获取使紫禁城暂停的信号编号。
WIFCONTINUED(status) 若在作用控制暂停后已近继续的子进程返回状态,则为真(仅用于waitpid)。

waitpid等待特定进程,其中pid参数用法为:

pid 说明
pid==-1 等待任一进程,此时与wait等效。
pid>0 等待进程ID与pid一致的子进程。
pid==0 等待组ID等于调用进程组ID的任一子进程。
pid<-1 等待组ID等于pid绝对值的任一子进程。

如果waitpid指定的进程不存在或者不是调用者的子进程就会报错。

通过options可以进一步控制waitpid操作,该参数要么是0(0是下面三个参数相与的结果),要么是下列参数位运算的结果:

常量 说明
WCONTINUED 若实现支持作业控制,那么由pid指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态。
WNOHANG 若由pid指定的子进程并不是立即可用的,则waitpid不阻塞,此时返回值为0。
WUNTRACED 若实现支持作业控制,而由pid指定的任一子进程已经处于停止状态,而且其状态自停止以来还未报告过,则返回其状态。WIFSTOPPED宏确定一个返回值是否是一个停止的子进程。

打印终止状态程序(将在下面经常用到):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include"apue.h"
#include<sys/wait.h>

void pr_exit(int status)
{
if(WIFEXITED(status))
{
printf("normal termination, exit status=%d\n",WEXITSTATUS(status));
}
else
{
if(WIFSIGNALED(status))
{
printf("abnormal termination,signal number =%d%s\n",WTERMSIG(status),
#ifdef WCOREDUMP
WCOREDUMP(status)?" (core file generated)":"");
#else
"");
#endif
}
else
{
if(WIFSTOPPED(status))
{
printf("child stopped,signal number = %d\n", WSTOPSIG(status));
}
}
}

}

利用上述函数展示终止状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include"apue.h"
#include<sys/wait.h>
int main(void)
{
pid_t pid;
int status;
if((pid=fork())<0)
{
err_sys("fork error\n");
}
else
{
if(pid==0) //终止子进程
{
exit(7);
}
}
if(wait(&status)!=pid) //获取子进程终止状态
{
err_sys("wait error\n");
}
else{
pr_exit(status);
}

if((pid=fork())<0)
{
err_sys("fork error\n");
}
else
{
if(pid==0) //终止子进程
{
abort();
}
}
if(wait(&status)!=pid) //获取子进程终止状态
{
err_sys("wait error\n");
}
else{
pr_exit(status);
}

if((pid=fork())<0)
{
err_sys("fork error\n");
}
else
{
if(pid==0) //终止子进程
{
status/=0;
}
}
if(wait(&status)!=pid) //获取子进程终止状态
{
err_sys("wait error\n");
}
else{
pr_exit(status);
}
exit(0);
}

再来考虑僵死进程,如果一个进程fork了一个子进程,但是并不像自己去等待进程终止也不想让其成为僵死进程直到父进程终止。此时,一个好的方式是调用两次fork。第一次创建一个子进程,第二次使用子进程再次创造一个子进程的子进程并且立即终止子进程,这样父进程不用等待子进程可以直接调用wait。而对于子进程的子进程来说,其父进程已经终止,其会被init收养,当退出时,init将会处理,使其不会成为一个僵死进程。例如下面的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include"apue.h"
#include<sys/wait.h>
int main()
{
pid_t pid;
if((pid = fork())<0)
{
err_sys("fork error\n");
}
else{
if(pid==0) // 子进程
{
if((pid=fork())<0)
{
err_sys("fork error\n");
}
else
{
if(pid>0) // 子进程终止,使子进程的子进程被init收养
exit(0);
else{
sleep(2); //保证子进程终止
printf("second child,parent pid = %ld\n", (long)(getppid()));
exit(0); //子进程的子进程终止,被init处理
}
}
}
}
if(waitpid(pid, NULL,0)!=pid) //等待子进程
{
err_sys("waitpid error\n");
}
exit(0);
}

运行:

1
2
$ ./fork_seconds.o 
$ second child,parent pid = 1

当原先的进程(父进程)终止时,shell打印其提示符,这在子进程的子进程打印其父进程ID之前。

函数waitid、wait3和wait4

1
2
#include<sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

waitid允许指定一个要等待的进程ID。使用两个单独的参数表示要等待的子进程所属类型。idtype选项如下:

常量 说明
P_PID 等待一个特定进程:id为要等待的进程ID。
P_PGID 等待一特定进程组的任一子进程:id包含要等待子进程的进程组ID。
P_ALL 等待任一进程,忽略id。

options参数是下列标志的位运算:

常量 说明
WCONTINUED 等待一进程,它曾经被停止,此后又已继续,但尚未报告。
WEXITED 等待已退出的进程。
WNOHANG 如无可用的子进程退出状态,立即返回而不阻塞。
WNOWAIT 不破坏子进程退出状态。
WSTOPPED 等待一个子进程,它已经停止但尚未报告。

wait3wait4在上述几个wait基础上多加了一个参数,使得内核返回由终止进程及其所有子进程使用的资源概况。

1
2
3
4
5
6
7
8
9
10
#include<sys/wait.h>
#include<sys/types.h>
#include<sys/time.h>
#include<sys/resource.h>

pid_t wait3(int *statloc, int option, struct rusage *rusage);

pid_t wait4(pid_t pid, int *statloc, int option, struct rusage *rusage);

//成功返回进程ID,出错返回-1

竞争条件

当多个进程都企图对共享数据进行某些处理,而最后的处理的结果又取决于进程运行的顺序时,我们认为发生了竞争条件。如果一个进程要等待子进程终止,则它必须使用wait函数中的一个。如果一个子进程要等待父进程的终止,可以使用下列的代码:

1
2
while(getppid()!=1)
sleep(1);

该方式被称为轮询,问题是浪费了CPU时间。

为了避免竞争和轮询,在多个进程之间需要有某种信号发送和接收的方法。可以使用信号机制,也开始使用进程间通信。均会在后面介绍。这里为了展示竞争条件,我们先使用下列几个函数,这些函数的实现都会在接下来的内容中讲解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include"apue.h"
TELL_WAIT(); //设置,为TELL_xxx和WAIT_xx做准备。
if((pid=fork())<0)
{
err_sys("fork error\n");
}
else
{
if(pid==0)
{
/*子进程处理程序*/
TELL_PARENT(getppid); //通知父进程自己处理完成
WAIT_PARENT(); //等待父进程
/*子进程等待父进程后接下来要处理的程序*/
exit(0);
}
/*父进程要处理的代码*/
TELL_CHILD(pid); //告诉子进程自己处理完成
WAIT_CHILD(); //等待子进程
/* 父进程再等待子进程后需要处理的代码*/
exit(0);
}

出现竞争条件的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include"apue.h"

static void charatatime(char *);

int main()
{
pid_t pid;
if((pid=fork())<0)
{
printf("fork error\n");
}
else{
if(pid==0)
{
charatatime("output from child\n");
}
else{
charatatime("output from parent\n");
}
}
exit(0);
}

static void charatatime(char *str)
{
char *ptr;
int c;
setbuf(stdout, NULL);//设置标准输出无缓冲,更方便看到竞争
for(ptr=str;(c=*ptr++)!=0;)
{
putc(c,stdout);
}
}

执行:

1
2
3
$ ./compare.o 
output from parenotu
tput from child

通过使用TELL和WAIT函数来解决竞争,程序变为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include"apue.h"

static void charatatime(char *);

int main()
{
pid_t pid;

TELL_WAIT();

if((pid=fork())<0)
{
printf("fork error\n");
}
else{
if(pid==0)
{
WAIT_PARENT();
charatatime("output from child\n");
}
else{
charatatime("output from parent\n");
TELL_CHILD(pid);
}
}
exit(0);
}

static void charatatime(char *str)
{
char *ptr;
int c;
setbuf(stdout, NULL);//设置标准输出无缓冲,更方便看到竞争
for(ptr=str;(c=*ptr++)!=0;)
{
putc(c,stdout);
}
}

这里先让父进程打印再打印子进程。这里的程序应该是无法执行的,这几个函数作者只在apue中给出了定义,并未实现,应该是希望通过之后的学习自己实现。

函数exec

当进程调用exec函数时,该进程执行的程序完全替换为新程序,而新程序则从main函数开始执行。因为exec并不创建新的进程,所以前后的进程ID并未改变。exec只是用磁盘上一个新的程序替换的当前进程的正文段、数据段、堆段和栈。基本进程原语是:使用fork创建新进程,用exec初始执行新的程序。exitwait函数处理终止和等待。

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

int execl(const char *pathname, const char *arg0, .../*(char *)0*/);

int execv(const char *pathname, char *const argv[]);

int execle(const char *pathname, const char *arg0, .../*(char *)0,char *const envp[]*/);

int execve(const char *pathname, char *coonst argv[], char *const envp[]);

int execlp(const char *filename, const char *arg0, .../*(char*)0*/);

int execvp(const char *filename, const char *argv[]);

int fexecve(int fd, char *const argv[], char *const envp[]);

//函数成功,不返回,否则返回-1

这七个函数之间的第一个区别为:前4个函数取路径名作为参数,后两个则使用文件名作为参数,最后一个取文件描述符作为参数。当使用filename时:

  1. 如果filname中存在/,就被视为路径名。
  2. 否则按照PATH环境变量,在其所指定的各个目录下搜索可执行文件。
1
2
PATH:
/usr/bin/:/usr/local/cuda-9.0/bin:/home/chst/.local/bin:/usr/bin/:/usr/local/cuda-9.0/bin:/home/chst/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games

如果execlpexecvp使用路径前缀中的一个找到一个可执行文件,但该文件不是连接编译器产生的机器可执行文件,则就认为是shell脚步,于是试着调用/bin/sh,并以filename作为输入。

第二个区别是:参数表的传递,l表示list,v表示矢量vector。execlexeclpexecle要求将新程序的每个命令行参数都说明为一个单独参数。这种参数表以空指针结尾。另外四个则是先构造一个指向各个参数的指针数组,然后传递数组地址。

第三个区别:向新进程传递参数表。以e结尾的函数传递一个指向环境字符串指针数组的指针。其他四个函数使用调用进程的environ变量为新程序赋值现有环境。

执行exec后,新进程从调用的进程继承了下列属性:

  1. 进程ID和父进程ID。
  2. 实际用户ID和实际组ID。
  3. 附属组ID。
  4. 进程组ID。
  5. 会话ID。
  6. 控制终端。
  7. 闹钟尚余留时间。
  8. 当前工作目录。
  9. 根目录。
  10. 文件模式创建屏蔽字。
  11. 文件锁。
  12. 进程信号屏蔽。
  13. 未处理信号。
  14. 资源限制。
  15. nice值。
  16. tms_utimetms_stimetms_cutime以及tms_cstime

对于打开的文件的处理与每个描述符的执行时关闭标志相关。详见第三章的fcntl函数节。如果设置了该标志,则执行exec时关闭,否则保持打开。对于打开的目录,POSIX.1要求exec时关闭。

exec前后实际用户ID和时间组ID保持不变,而有效ID是否改变取决于所执行的程序的设置用户ID位和设置组ID位。

七个exec只有execve是内核调用的,另外的函数都是库函数,它们最终都要调用内核函数。其关系如下:

exec

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include"apue.h"
#include<sys/wait.h>

char *env_init[] = {"USER=unknow","PATH=/tmp", NULL};

int main(void)
{
pid_t pid;
if((pid=fork())<0)
{
err_sys("fork error\n");
}
else{
if(pid==0){
if(execle("/home/chst/study_file/unix编程/test.o", "test.o", "myarg1", "MY ARG2", (char *)0, env_init)<0)
{
err_sys("execle error\n");
}
}
}
if(waitpid(pid,NULL, 0)<0)
{
err_sys("waitpid error\n");
}

if((pid=fork())<0)
{
err_sys("fork error\n");
}
else{
if(pid==0){
if(execlp("/home/chst/study_file/unix编程/test.o", "test.o", "myarg1", "MY ARG2", (char *)0)<0)
{
err_sys("execle error\n");
}
}
}
exit(0);
}

其中/home/chst/study_file/unix编程/test.o代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include"apue.h"
int main(int argc, char *argv[])
{
int i;
char **ptr;
extern char **environ;
for(i=0;i<argc;i++)
{
printf("argv[%d]: %s\n",i, argv[i]);
}

for(ptr = environ; *ptr!=0; ptr++)
{
printf("%s\n", *ptr);
}
exit(0);
}

更改用户ID和更改组ID

在UNIX中,特权以及访问控制是基于用户ID和组ID的。一般而言,在设计应用时,我们总是试图使用最小特权模型。依照此模型,我们总是给程序完成任务所需要的最小特权。

可以使用setuid设置实际用户ID和有效用户ID,用setgid函数来设置实际组ID和有效组ID:

1
2
3
4
5
6
#include<unistd.h>

int setuid(uid_t uid);

int setgid(gid_t gid);
//成功返回0,否则返回-1.

更改用户ID规则为:

  1. 若进程拥有超级用户权限,则setuid函数将实际用户ID、有效用户ID以及保存的设置用户ID设置为uid。
  2. 若进程没有超级用户权限,但uid等于实际用户ID或者保存用户ID,则setuid只将有效用户ID设置为uid而不改变实际用户ID和保存的设置用户ID。
  3. 如果上面两个都不满足,则erron设置为EPERM,并返回-1。

更改3个用户ID的方法:

ID exec exec setuid(uid) setuid(uid)
设置用户ID位关闭 设置用户ID为开启 超级用户 非特权用户
实际用户ID 不变 不变 设为uid 不变
有效用户ID 不变 设置为程序文件的用户ID 设为uid 设为uid
保存的设置用户ID 从有效用户ID复制 从有效用户ID复制 设为uid 不变

函数setreuidsetregid用来交换实际用户ID和有效用户ID。

1
2
3
#include<unistd.h>
int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);

如果任一参数值为-1,表示相应的ID应当保持不变。任意一个非特权用户都可以交换实际用户ID和有效用户ID。

函数seteuid和函数setegid类似于setuidsetgid,但只更改有效用户ID和有效组ID。

1
2
3
4
5
#include<unistd.h>
int seteuid(uid_t uid);

int setegid(gid_t gid);
//成功返回0,否则返回-1

不同函数更改ID的方式:

ID

解释器文件

解释器文件是文本文件,其起始行的形式为:

1
#! pathname [optional-agrument]

最常见的解释器文件以下列行开始:

1
#! /bin/sh

pathname通常是绝对路径。内核使调用exec函数的进程实际执行的不是该解释器文件,而是在该解释器文件第一行中pathname所指定的文件。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include"apue.h"
#include<sys/wait.h>

int main(void)
{
pid_t pid;
if((pid=fork())<0)
{
err_sys("fork error");
}
else{
if(pid==0)
{
if(execl("/home/chst/study_file/unix编程/inter", "inter", "myarg1", "MY ARG2", (char *)0)<0)
{
err_sys("excel error");
}
}
}
if(waitpid(pid, NULL, 0)!=pid)
{
err_sys("waitpid error");
}
exit(0);
}

其中/home/chst/study_file/unix编程/inter内容为:

1
#! /home/chst/study_file/unix编程/test.o foo

注意:需要使用chmod设置inter可执行

/home/chst/study_file/unix编程/test.o代码与上面第七节的一致,打印参数和环境表。

执行结果:

1
2
3
4
5
6
7
$ ./interpreter1.o 
argv[0]: /home/chst/study_file/unix编程/test.o
argv[1]: foo
argv[2]: /home/chst/study_file/unix编程/inter
argv[3]: myarg1
argv[4]: MY ARG2
......

从结果中可以看出,内核调用exec解释器时,argv[0]是解释器的pathname,argv[1]是解释器的可选参数。其余的参数是execl输入的参数。内核取第一个参数是pathname,而不是test.o

在解释器后可以跟随可选参数,例如可以执行python脚步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include"apue.h"
#include<sys/wait.h>

int main(void)
{
pid_t pid;
if((pid=fork())<0)
{
err_sys("fork error\n");
}
else{
if(pid == 0)
{
if(execl("/home/chst/study_file/unix编程/inter", "inter", (char *)0)<0)
{
err_sys("execl error\n");
}
}
}
if(waitpid(pid, NULL, 0)!=pid)
{
err_sys("waitpid error\n");
}
printf("C++ printf pid = %ld\n", (long)pid);
exit(0);
}

其中/home/chst/study_file/unix编程/inter内容为:

1
#! /usr/bin/python /home/chst/study_file/unix编程/getpid.py

其中/home/chst/study_file/unix编程/getpid.py代码为:

1
2
3
import os
w = os.getpid()
print("python printf pid = ",w,'\n')

执行后输出为:

1
2
3
4
$ ./interpreter.o 
python printf pid = 26401

C++ printf pid = 26401

这里也进一步验证了执行exec并不会额外创建新的进程。

之所以使用解释器有如下理由:

  1. 有些程序是用某些语言写的脚本,解释器可以将这个事实隐藏起来。
  2. 解释器在效率上提供了好处。
  3. 解释器脚本使我们可以使用除了/bin/sh以外的其他shell来编写shell脚本。当execlp找到一个非机器可执行文件时,它总是调用/bin/sh来解释执行该文件。但,用解释脚本则可以简单的写成:#! /bin/csh(在解释文件后跟随C shell)。

函数system

1
2
3
#include<stdlib.h>

int system(const char *cmdstring);

如果cmdstring是一个空指针,则仅当命令处理程序可用时,system返回非0,这一特征可以确定一个给定的操作系统上是否支持system。

由于system在其实现中调用了forkexecwaitpid,因此返回值有三种。

  1. fork失败或者waitpid返回除EINTR之外的出错,则system返回-1,并且设置erron以指示错误。
  2. 如果exec失败(表示不能执行shell),则其返回值如同shell执行了exit(127)一样。
  3. 否则三个函数都成功,那么system的返回值是shell的终止状态。

system函数的一种实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include<sys/wait.h>
#include<errno.h>
#include<unistd.h>

int system(const char *cmdstring)
{
pid_t pid;
int status;
if(cmdstring == NULL)
{
return 1;
}

if((pid = fork())<0)
{
status = -1;
}
else{
if(pid==0)
{
execl("/bin/sh", "sh", "-c", cmdstring, (char*)0); //如果程序正常执行,则会自己调用exit函数,否则使用下一条命令。
_exit(127);
}
else{
while (waitpid(pid, &status, 0)<0)
{
if(errno != EINTR)
{
status = -1;
break;
}
}
}
}
return status;
}

shell-c选项告诉shell程序取下一个命令行参数(这里是cmdstring)作为命令行输入。

使用上面的system函数测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include<sys/wait.h>
#include<errno.h>
#include<unistd.h>
#include"apue.h"
#include"pr_exit.h"
#include"system.h"

int main(void)
{
int status;
if((status = system("date"))<0)
{
err_sys("system() error");
}
pr_exit(status);
if((status = system("nosuchcommand"))<0)
{
err_sys("system() error");
}

pr_exit(status);

if((status = system("who; exit 44"))<0)
{
err_sys("system() error");
}

pr_exit(status);

exit(0);
}

执行结果:

1
2
3
4
5
6
7
$ ./system.o 
Thu 3 Oct 01:05:35 +08 2019
normal termination, exit status=0
sh: 1: nosuchcommand: not found
normal termination, exit status=127
chst :0 2019-10-02 23:24 (:0)
normal termination, exit status=44

注意:设置用户ID或设置用户组ID的程序决不应该调用system函数。

进程标识

1
getpwuid(getuid());

获得运行该程序用户的登录名。

1
2
3
4
5
#include<unistd.h>

char *getlongin(void);

//正常返回指针,否则返回NULL

还有该函数可以获得用户登录时使用的名字。

获得登录名后可以使用getpwnam(第六章)获得口令文件。

进程调度

调度策略和调度优先级由内核决定。进程可以通过调整nice值来选择以更低优先级运行(通过调整nice值降低对CPU的占有)。只有特权进程允许提高调度权限。

nice的值在0~(2*NZERO-1)之间。nice值越小,优先级越高。

通过nice函数,我们可以更改进程nice值:

1
2
3
#include<unistd.h>
int nice(int incr);
//incr为在当前nice值基础上增加的数量。正确返回返回新的nice值,否则返回-1

由于-1是合法返回值,,因此判断错误应该使用errno

getpriority函数获得进程nice值,还可以获取一组相关进程的nice值:

1
2
3
4
#include<sys/resource.h>

int getpriority(int which, id_t who);
//正常返回nice值,错误返回-1

which参数为:PRIO_PROCESS表示进程,PRIO_PGRP表示进程组,PRIO_USER表示用户ID。which控制参数who如何解释,who参数选择感兴趣的一个或多个进程。如果who参数为0,表示调用进程、进程组或者用户(取决与which)。如果which参数设置为PRIO_USERwho为0,使用调用进程的实际用户ID。如果which作用于多个进程,则返回进程中优先级最高的(nice最小的)。

setpriority函数用于为进程、进程组和属于特定用户ID的所以进程设置优先级:

1
2
3
#include<sys/resource.h>

int setpriority(int which, id_d who, int value);

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include"apue.h"
#include<sys/time.h>
#include<errno.h>

#if defined(MACOS)
#include<sys/syslimits.h>
#elif defined(SOLARIS)
#include<limits.h>
#elif defined(BSD)
#include<sys/param.h>
#endif

unsigned long long count;
timeval end;

void checktime(char *str)
{
timeval tv;
gettimeofday(&tv, NULL);
if(tv.tv_sec > end.tv_sec && tv.tv_usec > end.tv_usec){
printf("%s count = %lld\n", str, count);
exit(0);
}
}

int main(int argc, char *argv[])
{
pid_t pid;
char *s;
int nzero,ret;
int adj = 0;
setbuf(stdout, NULL);

#if defined(NZERO)
nzero = NZERO;
#elif defined(_SC_NZERO)
nzero = sysconf(_SC_NZERO);
#else
#error NZERO undefined
#endif

printf("NZERO = %d\n", nzero);
if(argc == 2)
{
adj = strtol(argv[1], NULL, 10);//字符串转换为长整型
}
gettimeofday(&end, NULL);
end.tv_sec += 3;

if((pid=fork())<0)
{
err_sys("fork error");
}
else{
if(pid == 0){
s = "child";
printf("current nice value in child is %d, adjusting by %d\n", nice(0)+nzero, adj);
errno = 0;
if((ret=nice(adj))==-1 && errno != 0)
{
err_sys("child set nice error");
}
printf("child now nice value is %d\n", ret+nzero);
}
else{
s = "parent";
printf("current nice value of parent is %d\n", nice(0)+nzero);
}
while(1)
{
if(++count==0)
{
err_sys("%s conter warp",s);
}
checktime(s);
}
}
}

例子通过将子进程的nice增加来展示两个进程累加次数,但在我的计算机上好像没啥区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ./nice.o 20
NZERO = 20
current nice value of parent is 20
current nice value in child is 20, adjusting by 20
child now nice value is 39
child count = 590759158
parent count = 589967528

$ ./nice.o 20
NZERO = 20
current nice value of parent is 20
current nice value in child is 20, adjusting by 20
child now nice value is 39
parent count = 590076641
child count = 590084625

进程时间

我们可以度量的有三个时间:墙上时钟时间、用户CPU时间和系统CPU时间。任一进程都可以调用times函数获得自己和已经终止子进程的上述值:

1
2
3
#include<sys/times.h>
clock_t times(struct tms *buf);
//成功返回流逝的墙上时钟时间(以时钟滴答数为计数单位),出错返回-1

buf结构为:

1
2
3
4
5
6
struct tms{
clock_t tms_utime;//用户CUP时间
clock_t tms_stime;//系统CPU时间
clock_t tms_cutime;//已经终止的子进程的CPU时间
clock_t tms_cstime;//已经终止的子进程的系统时间
}

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include"apue.h"
#include<sys/times.h>
#include"pr_exit.h"

static void pr_times(clock_t ,tms *, tms *);
static void do_cmd(char *);

int main(int argc, char*argv[])
{
int i;
setbuf(stdout, NULL);
for(i=1;i<argc; i++)
{
do_cmd(argv[i]);
}
exit(0);
}

static void do_cmd(char *cmd)
{
tms tmsstart, tmsend;
clock_t start, end;
int status;

printf("\ncommend:%s\n",cmd);
if((start=times(&tmsstart))==-1)
{
err_sys("times error");
}

if((status = system(cmd))<0)
{
err_sys("system error");
}

if((end = times(&tmsend))<0)
{
err_sys("times error");
}
pr_times(end-start, &tmsstart, &tmsend);
pr_exit(status);
}

static void pr_times(clock_t real, tms *tmsstart, tms *tmsend)
{
static long clktck = 0;
if(clktck == 0)
{
if((clktck = sysconf(_SC_CLK_TCK))<0) //获取每秒时钟滴答数
{
err_sys("sysconf error");
}
}

printf(" real: %7.2f\n", real/double(clktck));
printf(" user: %7.2f\n", (tmsend->tms_utime - tmsstart->tms_utime)/(double)clktck);
printf(" sys: %7.2f\n", (tmsend->tms_stime-tmsstart->tms_stime)/(double)clktck);
printf(" child user: %7.2f\n", (tmsend->tms_cutime-tmsstart->tms_cutime)/double(clktck));
printf(" child sys: %7.2f\n", (tmsend->tms_cstime-tmsstart->tms_cstime)/(double)clktck);
}

该程序执行输入参数的命令行命令并输出对于时间信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ ./ptime.o "sleep 5" "date" "man bash > /home/chst/study_file/unix编程/output.txt"

commend:sleep 5
real: 5.01
user: 0.00
sys: 0.00
child user: 0.00
child sys: 0.00
normal termination, exit status=0

commend:date
Thu 3 Oct 13:54:01 +08 2019
real: 0.00
user: 0.00
sys: 0.00
child user: 0.00
child sys: 0.00
normal termination, exit status=0

commend:man bash > /home/chst/study_file/unix编程/output.txt
real: 0.21
user: 0.00
sys: 0.00
child user: 0.33
child sys: 0.04
normal termination, exit status=0

书中题目8.6:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include<sys/wait.h>
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>

int main()
{
pid_t pid;
if((pid = fork())<0)
{
printf("fork error\n");
}
else{
if(pid==0)
{
exit(0);
}
else{
sleep(2); //等待子进程结束
char w[100] = "ps ";
int n = strlen(w);
printf("%d\n", n);
char id[10];
int i=0;
long int p= long(pid);
while(p)
{
id[i++] = char(p%10 + '0');
p /= 10;
}
for(int k = n; k<n+i;k++)
{
w[k] = id[n+i-1-k];
}
w[n+i] = '\0';
printf("command = %s\n", w);
int status = 0;
if((status = system(w))<0)
{
printf("system error\n");
}
}
}
exit(0);
}

第九章 进程关系

终端登录

系统管理者创建通常名为/etc/ttys的文件(我的Ubuntu并没有这个文件),其中每个终端设备都有一行,每一行说明设备名和传到getty程序的参数。当程序自举时,内核创建进程为1的init进程。init进程使系统进入多用户模式。init读取文件/etc/ttys,对每一个允许登录的终端设备,init调用一次fork,所生成的子进程exec getty程序。如下图9-1:

init

getty对终端设备调用open函数,以读写方式将终端打开。一旦设备被打开,文件描述符0、 1、 2就会被设置到该设备上。然后getty输出login:之类提示符等待用户输入用户名。随后以类似于下面的方式调用login程序:

1
execle("/bin/login", "login", "-p", username, (char*)0, envp);

init以空环境表调用gettygetty以终端和在gettytab中说明的环境字符串为login创建一个环境。-p标志通知login保留传递给它的环境,也可以将其他环境字符串加到该环境中,但不要替换它。上图9-2显示了login刚被调用后这些进程的状态。

图9-2下面的三个进程的ID相同,因为调用exec并不创建新的进程。

login会调用getpwnam获取响应用户的口令文件登录项。调用getpass提示输入password。对比用户输入密码与登录项的pw_passwd是否一致。如果多次都无效,则login以1调用exit表示登录失败。父进程init了解到后,将再次调用fork,其后执行getty

用户正常登录,login就将完成如下工作:

  1. 将当前工作目录更改为该用户的起始目录(chdir)。

  2. 调用chown更改该终端的所有权,使登录用户成为它的所有者。

  3. 将对该终端设备的访问权限改变为“用户读和写”。

  4. 调用setgidinitgroups设置进程的进程组ID。

  5. login得到的所以信息初始化环境:起始目录(HOME),shell(SHELL),用户名(USERLOGNAME)以及一个系统默认路径(PATH)。

  6. login进程更改为登录用户的用户ID(setuid),并调用该用户的登陆shell,其方式类似于:

    1
    execl("/bin/sh", "-sh",(char*)0);

至此,登录shell开始运行。其父进程ID是init,所以当此登录shell终止时,init会得到通知(SIGCHILD信号),它会重复上述全部过程。如下图:

login

网络登录

通过串行登录至系统和经由网络登陆至系统两者主要区别是:网络登录时,在终端和计算机之间的连接不再是点到点的。在网络登陆下,login仅仅是一种可用的服务,这与其他网路服务(如FTP或SMTP)的性质相同。为了同一个软件既能处理终端登录,又能处理网络登录,系统使用了一种称为伪终端的软件驱动程序。

在上一节的终端登录中,init知道那些终端设备可以用来进行登录,并为每一个设备生成一个getty进程。但网络登录情况下,所以登录均由内核的网路接口驱动程序,而且事先并不知道会有多少这样的登录。因此必须等待一个网路连接请求的到达,而不是使一个进程等待每一个可能的登录。

在BSD中,有一个inetd进程(因特网超级服务器),它等待大多数网络连接。init调用shell,使其执行shell脚本/etc/rc。由此shell脚本启动一个守护进程inetd。一旦此shell脚本终止,inetd的父进程就变成initinetd等待TCP/IP连接请求达到主机,而当一个连接请求到达主机时,它执行一次fork,然后子进程exec适当的程序。

假定一个TELNET服务进程的TCP连接请求到达。TELNET是使用TCP协议的远程登录应用程序。客户进程通过telnet hostname启动登录过程。该客户进程打开一个到hostname主机的TCP连接,在hostname主机上启动的程序被称为TELNET服务进程。然后,客户进程和服务进程之间通过使用TELNET应用协议通过TCP连接交换数据。下图展示了这一过程:

TELNET

随后telnetd进程打开一个伪终端,并fork分成两个进程。父进程处理通过网络传输的信息,子进程执行login程序。父进程和子进程通过终端相连。在调用exec之前,子进程使其文件描述符0、 1、 2与伪终端相连。如果登录正确则执行上一节所述步骤。然后login调用exec将其自身替换为登录用户的登录shell。下图展示了这一过程:

login

注意:当通过终端或网络登录时,我们得到一个登录shell,其标准输入、标准输出、标准错误要么连接到一个终端,要么连接到伪终端设备上。

进程组

每个进程除了有一个进程ID之外,还属于一个进程组进程组是一个或多个进程的合集,通常他们是在同一作业中结合起来的,同一进程组中的进程接收来自统一终端的各种信号。每个进程组有一个唯一的进错组ID。进程组ID是一个正整数,保存在pid_t数据类型中。函数getpgrp返回调用进程的进错组ID:

1
2
3
4
#include<unistd.h>

pid_t getpgrp(void);
//成功返回进程组ID,否则返回-1

getpgid函数可以传递进程ID,获取进程组ID:

1
2
3
4
#include<unistd.h>

pid_t getpgid(pid_t pid);
//成功返回进程组ID,否则返回-1

如pid是0,返回调用进程的进错组ID,于是getpgid(0) = getpgrp()

每个进程组存在一个组长进程。组长进程的进程组ID等于其进程ID。

注意:进程组组长可以创建一个进程组(fork生成的子进程其进程组ID也会从父进程中继承过来,且exec不改变进错组ID)、创建该组中的进程,然后终止。只要在某一个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。从进程组创建开始到其中最后一个进程离开为止的时间称为进程组的生命周期。某个进程组的最后一个进程可以终止,也可以转移到另一个进程组中。

进程调用setpgid可以加入一个现有的进程组或者创建一个新的进程组:

1
2
3
#include<unistd.h>
int setpgid(pid_t pid, pid_t pgid);
//成功返回0,失败返回-1

setpgidpid进程的进程组ID设置为pgid。如果两个参数相等,则由pid指定的进程变成进程组组长。如果pid是0,则使用调用者的进程ID。如果pgid是0,则由pid指定的进程ID作为进程组ID。

一个进程只能为它自己或它的子进程设置进程组ID。在它的子进程调用exec后,它就不再更改子进程的进程组ID。

在大多数作业控制shell中,在fork之后调用此函数,使父进程设置子进程的进程组ID,并且也使子进程设置其自己的进程组ID。这两个调用有一个是冗余的,但为了保证设置确实发生了。

会话

会话是一个或多个进程组的集合,例如下图:

会话

通常是由shell的管道将几个进程编写成一组的,上图的安排可能是下面的命令:

1
2
proc1 | proc2 &
proc3 | proc4 | proc5

进程调用setsid函数建立一个新的会话:

1
2
3
4
#include<unistd.h>

pid_t setsid(void);
//成功返回进程组ID,否则返回-1

如果调用该函数的进程不是一个进程组的组长,则此函数创建一个新会话。具体会发生下面三件事:

  1. 该进程变成新会话的会话首进程(会话首进程是创建该会话的进程)。此时,该进程是新会话的唯一进程。
  2. 该进程成为一个新进程组的组长进程,进程组ID为该进程的进程ID。
  3. 该进程没有控制终端(下一节讨论控制终端)。如果在调用setsid之前该进程有一个控制终端,那么这种联系也会被切断。

如果该调用进程是一个进程组的组长,则会报错。为了防止这种情况发生,一般先调用fork创建新进程,然后使父进程终止,在子进程中调用该函数。

将会话首进程的进程ID视为会话ID。getsid获得会化首进程的进程组ID:

1
2
3
4
#include<unistd.h>

pid_t getsid(pid_t pid);
//成功返回会话首进程的进程组ID,否则返回-1

如果pid是0,则返回调用进程的会话首进程的进程组ID。

控制终端

会话和进程组还有如下特性:

  1. 一个会话可以有一个控制终端。这通常是终端设备或伪终端设备。
  2. 建立与控制终端连接的会话首进程被称为控制进程。
  3. 一个会话中的几个进程组可被分为一个前台进程组和一个或者多个后台进程组。
  4. 如果一个会话有一个控制终端,则它有一个前台进程组,其它进程组为后台进程组。
  5. 无论何时键入终端的中断键(常常是Delete或者Ctrl+C,都会将中断信号发送至前台进程组的所以进程。
  6. 无论何时键入终端的退出键(常常是Ctrl+\),都会将退出信号发送到前台进程组的每个进程。
  7. 如果终端接口检测到调制解调器(或网络)已经断开连接,则将挂断信号发送至控制进程(会话首进程)。

特性展示如下图:

终端

通常,我们不必担心控制终端,登录时,将自动创建控制终端。有时不管标注输入输出是否重定向,我们都需要与终端交互。保证程序能够与控制终端对话的方法是open文件/dev/tty。在内核中,次特殊文件是控制终端的同义语。当程序没有控制终端,则对此文件的打开会失败。

i会话分配控制终端的两种方式:一、当会话首进程用TIOCSCTTY作为request参数(第三个参数为空指针)调用ioct1时,系统会为会话分配控制终端。二、当会话首进程打开第一个未与会话关联的终端设备时,只要在调用open时不指定O_NOCTTY,系统将次作为控制终端分配给次会话。

函数tcgetpgrp、tcsetpgrp和tcgetsid

需要有一种方法告诉内核哪一个进程组是前台进程组,这样,终端设备驱动程序就能知道将终端输入和终端产生的信号发送到何处:

1
2
3
4
5
6
#include<unistd.h>
pid_t tcgetpgrp(int fd);
//成功返回前台进程组ID,否则返回-1;

int tcsetpgrp(int fd, pid_t pgrpid);
//成功返回0,否则返回-1

函数tcgetpgrp返回前台进程组ID,它与在fd上打开的终端相关联。

如果进程有一个控制终端,则该进程可以调用tcsetpgrp将前台进程组ID设置为`pgrpidpgrpid应当是在同一会话中的一个进程组ID。fd必须引用该会话控制终端。

给出控制TTY的文件描述符,通过tcgetsid函数,应用程序就能获得会话首进程的进程组ID:

1
2
3
#include<termios.h>
pid_t tcgetsid(int fd);
//成功返回会话首进程的进程组ID,否则-1

作业控制

作业控制运行在一个终端上启动多个作业(进程组)。器控制哪个进程可以访问该终端以及那些作业在后台运行。作业控制要求以下三种支持:

  1. 支持作业控制的shell。
  2. 内核中的终端驱动程序必须支持作业控制。
  3. 内核必须提供对某些作业控制信号的支持。

通常,在shell里面输入命令默认产生的是前台进程,在一般命令后加上一个&即将其转换为后台进程。

如:

1
2
3
4
5
6
vim mian.cpp
//前台进程

pr *.c | lpr &
make all &
//两个后台进程

当启动一个后台进程是,shell将会赋予它一个作业标识符,并打印一个或多个进程ID:

如:

1
2
3
4
5
6
7
$ ls -l > file.txt &
[1] 29107
$ cp file.txt file2.txt &
[2] 29110
[1] Done ls --color=auto -l > file.txt
$
[2]+ Done cp file.txt file2.txt

ls是的编号作业是1,cp的作用编号是2。当作业完成且键入回车时,shell通知作业已经完成。

有三个特殊字符可使终端驱动程序产生信号,并将他们发送到前台进程组:

  1. 中断字符(DeleteCtrl+c)产生SIGINT。
  2. 退出字符(Ctrl+\)产生SIGQUIT。
  3. 挂起字符(一般采用Ctrl+Z)产生SIGTSTP。

只有前台作业接收终端输入。如果后台作业试图读终端,并不是一个错误,但终端会检测到这种情况,并且向后台作业发送一个特定信号SIGTTIN。该信号会停止次后台作业,而shell则向有关用户发出这种情况的通知,然后用户使用shell指令将次作业转换为前台作业,于是就可以正常读终端了。

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cat > temp.foo &
[1] 30285
$

[1]+ Stopped cat > temp.foo
$ fg %1 //将作业转换为前台作业
cat > temp.foo

hello word
^D //键入文件结束符
$ cat temp.foo

hello word

cat > temp.foo命令是读取终端输入,输出到temp.foo文件里。当其想要读取时,终端驱动知道其为后台作业,发送信号SIGTTIN使作业停止。当将其转换为前台进程后,终端驱动发送继续信号SIGCONT给进程组。

对于后台工作输出到终端,这是一个我们可以允许或禁止的选项。可以使用stty改变这一选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cat temp.foo &
[1] 370
$
hello word

[1]+ Done cat temp.foo
$ stty tostop
$ cat temp.foo &
[1] 385
$

[1]+ Stopped cat temp.foo
$ fg %1
cat temp.foo

hello word

stty tostop禁止后台程序输出到控制终端,此时驱动程序发现该写操作来自于后台进程,于是向该作业发送SIGTTOU信号,cat信号阻塞。当使用fg %1将进程转为前台时,作业继续执行。

下图展示了作业控制的功能:

作业控制

shell执行程序

执行下面的指令:

1
2
3
4
$ ps -o pid,ppid,pgid,sid,tpgid,comm
PID PPID PGID SID TPGID COMMAND
4150 32758 4150 32758 4150 ps
32758 32752 32758 32758 4150 bash

可以看出,ps进程是bash的子进程,但shell将前台作业(ps)放入自己的进程组。ps是进程组组长,也是该进程组唯一进程,此进程具有控制终端,因此是前台进程组。

再执行下面的命令:

1
2
3
4
5
6
7
8
$  ps -o pid,ppid,pgid,sid,tpgid,comm &
[1] 4241
$
PID PPID PGID SID TPGID COMMAND
4233 4227 4233 4233 4233 bash
4241 4233 4241 4233 4233 ps

[1]+ Done ps -o pid,ppid,pgid,sid,tpgid,comm

可以看到,此时前端进程组是bash

再来看下面的指令:

1
2
3
4
5
$ ps -o pid,ppid,pgid,sid,tpgid,comm | cat
PID PPID PGID SID TPGID COMMAND
4233 4227 4233 4233 4296 bash
4296 4233 4296 4233 4296 ps
4297 4233 4296 4233 4296 cat

pscat都在一个新的进程组组中,这是一个前台进程。注意:对于管道来说,上一条指令的输出是下一条指令的输入,因此,管道的最后一个进程(最后一个命令生成的进程)是shell的子进程,而执行管道中其他目录的进程则是该最后进程的子进程。可以理解为最后的一条指令生成的进程再fork一个新进程,执行之前的指令,再讲执行结果作为执行本身进程的输入。

孤儿进程组

定义:该组中每个成员的父进程要么是该组的一个成员,要么不是该组所属会话的成员。另一种描述为:一个进程不是孤儿进程的条件是:该组中存在一个进程,其父进程在属于同一会话的另一个组中。如果进程不是孤儿进程组,那么在属于同一会话的另一个组中的父进程就有机会重新启动该组中停止的进程。

POSIX.1要求向孤儿进程组中处于停止状态的每一个进程发送挂断信号(SIGHUP),接着又向其发送继续信号。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include"apue.h"
#include<error.h>

//接受到SIGHUP的处理函数,如果没有该函数,默认是终端进程
static void sig_hup(int signo)
{
printf("SIGHUP received, pid = %ld\n", (long)getpid());
}

static void pr_ids(char *name)
{
printf("%s: pid =%ld, ppid=%ld, tgprg = %ld\n", name, (long)getpid(), (long)getppid(),
(long)tcgetpgrp(STDIN_FILENO)); //通过标准输入的fd获取会话首进程
fflush(stdout);
}

int main(void)
{
char c;
pid_t pid;

pr_ids("parent");

if((pid = fork())<0)
{
err_sys("fork error");
}
else{
if(pid>0)
{
sleep(5); // 暂停5秒,使子进程暂停
}
else{
pr_ids("child");
signal(SIGHUP, sig_hup); //绑定信号与处理函数
kill(getpid(), SIGTSTP); //自己给自己发送信号,使自己暂停
pr_ids("child"); //当接受到内核的SIGHUP和SIGCONT后继续执行。
if(read(STDIN_FILENO, &c, 1)!=1)
{
printf("read error %d on controlling TTY\n",errno);
}
}

}
exit(0);
}

执行:

1
2
3
4
5
6
$ ./opg.o 
parent: pid =7646, ppid=6151, tgprg = 7646
child: pid =7647, ppid=7646, tgprg = 7646
SIGHUP received, pid = 7647
child: pid =7647, ppid=1, tgprg = 6151
read error 5 on controlling TTY

这里,当父进程终止时,子进程就会变为后台进程组,因为父进程是由shell作为前台作业执行的。当子进程继续执行时,企图从终端读取输入,但此时已经变成在后台进程组,于是内核向其发送SIGTTIN,但此时其为孤儿进程,如果进程是由该信号停止它,则此进程再也不会继续。

FreeBSD实现

下图展示了进程,进程组,会话和控制终端是如何实现的:

实现

session结构开始说明。每个会话都会分配一个session结构。

  1. s_count是当前会话中的进程组数。当到达0时即可以释放该结构。
  2. s_leader是指向会话首进程proc结构的指针。
  3. s_ttyvp是指向控制终端vnode结构的指针。
  4. s_ttyp是指向控制终端tty结构的指针。
  5. s_sid是会话ID。

在调用setsid时,在内核中分配一个新的session结构。s_count设置为1,s_leader设置为调用进程proc结构的指针,s_sid设置为进程ID,由于新会话没有控制终端,所以s_ttyvps_ttyp设置为空指针。

接着说TTY结构。每个终端设备和每个伪终端设备均会在内核分配这样的一种结构。

  1. t_session指向将此终端作为控制终端的session结果。终端在失去载波信号时使用此指针将挂起信号发送给会话首进程。
  2. t_pgrp指向前台进程组的pgrp结构。终端驱动用次字段将信号发送到前台进程组。
  3. t_termios包含所以这些特殊字符和与终端有关信息的结构。
  4. t_winsize是包含终端窗口大小的winsize型结构。

为了找到特定前台进程,内核从会话开始,使用s_ttyp得到控制终端的tty结构,再用t_pgrp得到前台进程组的pgrp结构。

pgrp包含特定进程组信息。

  1. pg_id是进程组ID。
  2. pg_session指向此进程所属会话的session结构。
  3. pg_members指向次进程组的proc结构表的指针proc代表进程组成员,proc结构中的p_pglist是一个双向链表,指向该组中的下一个和上一个进程。直到遇到最后一个进程,它的procp_pglist为空。

proc包含一个进程的信息

  1. p_pid进程ID。
  2. p_pptr指向父进程proc的指针。
  3. p_pgrp指向本进程所属的进程组pgrp结构的指针。
  4. p_pglist是一个结构。包含两个指针,指向进程组中上一个和下一个进程

最后还有一个vnode结构。在打开控制终端设备时分配此结构。进程对/dev/tty的所以访问都是通过vnode结构。

第十章 信号

信号概念

信号是软件中断。信号提供了一种处理异步事件的方法。每个信号都有名字,名字都以SIG开头。

很多条件可以产生信号:

  1. 当用户按下某些终端键时,引发终端产生的信号。
  2. 硬件异常产生信号:除数为0、无效的内存引用等。这些条件通常由硬件检测到,并通知内核。内核为进程产生适当的信号。
  3. 进程调用kill函数可以将任意信号发送到另一个进程或者进程组。对此存在限制:发送信号的进程所有者应该与接受信号的进程所有者一致,或者发送信号的进程所有者为超级用户。
  4. 用户可以使用kill指令将信号发送到其他进程。该指令是kill函数的接口。常用此命令终止一个失控的后台进程。
  5. 当检测到某些软件条件已经发送,并应将其通知有关进程时也产生信号。这里指的是软件条件,如进程设置的定时闹钟已超时。

信号是异步事件的经典实例。产生信号的事件对进程而言是随机出现的。进程不能简单的测试一个变量来判断是否发送了一个信号,而是告诉内核“在此信号发生时,请执行下列操作”。

当某个信号发生时,可以告诉内核按下列3中方式之一进行处理:

  1. 忽略次信号。大多数内核按照这种方式进行处理。但两种信号绝对不能忽略。是SIGKILLSIGSTOP。它们向内核和超级用户提供了使进程终止或者停止的可靠方法。另外,如果忽略某些硬件异常的信号,则进程的行为是未定义的。
  2. 捕捉信号。为了做到这一点,要通知内核在某种信号发生时,调用一个用户函数。在用户函数中,可执行用户希望对这种事件的处理。
  3. 执行默认动作。大多数信号的系统默认动作是终止进程。

在系统默认动作中,“终止+core”表示在进程当前工作目录的core文件中复制该进程的内存映像。大多数UNIX系统调试程序都使用core文件检查进程终止时的状态。

下面列出各个信号相关信息:

信号 说明 默认动作 详细说明
SIGABRT 异常终止(abort 终止+core 调用abort时,产生次信号。进程异常终止。
SIGALRM 定时器超时(alarm 终止 当用alarm函数设置的定时器超时,或由setitimer函数设置的间隔时间已经超时时,产生次信号。
SIGBUS 硬件故障 终止+core 指示一个实现定义的硬件故障。当出现某种类型的内存故障时,实现常常产生此信号。
SIGCANCEL 线程库内使用 忽略 Solaris线程库内使用。
SIGCHLD 子进程改变状态 忽略 在一个进程终止或者暂停时,该信号被发送到其父进程。如果父进程希望被告知其子进程这种状态,则应该捕捉信号,在捕捉函数中调用一种wait函数以获得子进程ID和状态。
SIGCONT 使暂停程序继续 忽略 如果接到此信号的进程处于停止状态,在系统默认动作是进程继续执行,否则忽略此信号。
SIGEMT 硬件故障 终止+core 指示一个实现定义的硬件异常。
SIGFPE 算数异常 终止+core 算数运算异常,如除0、浮点数溢出。
SIGFREEZE 检查点冻结 忽略 仅由Solaries定义。
SIGHUP 连接断开 终止 如果终端接口检测到一个连接断开,将此信号送给与终端进程相关的控制进程。接到此信号的会话首进程可能在后台。如果会话首进程已经终止,也产生此信号,则将信号发送给前台进程组。通常用此信号通知守护进程再次读取他们的配置文件。
SIGILL 非法硬件指令 终止+core 进程已执行一条非法硬件指令。
SIGINFO 键盘状态指令 忽略 一种BSD硬件指令。当用户按状态键(Ctrl+T)时发送信号到前段进程组。
SIGINT 终端中断符 终止 当用户按终端键(DeleteCtrl+C)时,终端驱动程序发送信号取前台进程组中每一个进程。当一个进程失控时常使用该方式结束进程。
SIGIO 异步I/O 终止/忽略 此信号指示一个一个异步I/O
SIGLIOT 硬件故障 终止+core 指示一个实现定义的硬件故障
SIGJVM1 Java虚拟机内部使用 忽略 Solaris为Jave虚拟机预留的信号。
SIGJVM2 Java虚拟机内部使用 忽略 Solaris为Jave虚拟机预留的信号。
SIGKILL 终止 终止 杀死进程
SIGLOST 资源丢失 终止 只在Solaris中存在。
SIGLWP 线程库内使用 终止/忽略 Solaris内线程库内使用。
SIGPIPE 写至无读进程的管道 终止 在管道的读进程已经终止时写管道,或类型为SOCK_STREAM的套接字已不再连接时,写该套接字会产生此信号。
SIGPOLL 可轮询时间(poll) 终止 当一个可轮询事件发生一个特定事件时产生此信号。
SIGPROF 梗概时间超时(setitimer 终止 setitimer函数设置的梗概统计间隔定时器已经产生超时信号时产生。
SIGPWR 电源失效/重启动 终止/忽略 接到蓄电池电压过低信息的进程将信号SIGPWR发送给init进程,而后init进程处理停机操作。
SIGQUIT 终端退出符 终止+core 当用户在终端按下退出键(Ctrl+\),中断驱动程序产生此信号,并发送给前台进程组的所有进程。
SIGSEGV 无效内存引用 终止+core 进程进行了一次无效的进程引用,通常说明程序有错。
SIGSTKFLT 协处理器栈故障 终止 并非由内核产生,只在早期Linux中存在。
SIGSTOP 停止 停止进程 作业控制信号,停止进程。不能被捕捉或忽略。
SIGSYS 无效系统调用 终止+core 指示一个无效的系统调用,指令指示系统调用类型参数是无效的。常发生在不同系统间。
SIGTREM 终止 终止 这是由kill命令发送的系统默认终止信号。该信号是可以捕获的,相对与SIGKILL,我们可以在终止前进行必要的处理。
SIGTHAW 检查点解冻 忽略 Solaris定义。
SIGTHR 线程库内部使用 忽略 FreeBSD预留线程库信号。
SIGTRAP 硬件故障 终止+core 指示一个实现定义的硬件故障
SIGTSTP 终端停止符 停止进程 交互停止信号,当用户在终端上按挂起键(Ctrl+Z)时,终端驱动程序产生此信号,该信号发送至前段进程组的所以进程。
SIGTTIN 后台读控制tty 停止进程 后台进程组进程试图读其控制终端时,终端驱动产生此信号。下列两个情况不产生:1. 读进程忽略或阻塞此信号。2.进程所属为孤儿进程组,读进程返回错误,errno设置为EIO
SIGTTOU 后台写向控制tty 停止进程 与删一条类似,不过是后端向所属控制终端写。
SIGURG 紧急情况(套接字) 忽略 通知进程已经发生一个紧急情况。在网络连接上接到带外的数据时,可选择的产生此信号。
SIGUSER1 用户定义信号 终止 用户定义信号,可用于程序
SIGUSER2 用户定义信号 终止 用户定义信号,可用于程序
SIGVTALRM 虚拟时间闹钟(setitimer 终止 当一个由setitimer函数设置的虚拟时间间隔时间已经超时时产生此信号。
SIGWAITING 线程库内使用 忽略 Solaris线程库内部使用。
SIGWINCH 终端窗口改变 忽略 如果进程用ioct1的设置窗口大小命令更改了窗口大小,则内核将此信号发送至前台进程组。内核维持与每个终端与伪终端相关联的窗口大小。
SIGXCPU 超过CPU限制(setrlimit 终止或终止+core 进程超过其软CPU限制,会产生该信号。
SIGXFSZ 超过文件长度限制(setrlimit 终止或终止+core 如果进程超过其软文件长度限制,则产生该信号。
SIGXRES 超过资源限制 忽略 仅由Solaris定义。

函数signal

1
2
3
4
#include<signal.h>

void (*signal(int signo, void(*func)(int)))(int);
//成功返回以前的信号处理配置,若出错,返回SIG_ERR

signo是信号名,fun是常量SIG_IGN、常量SIG_DFL或当接到此信号后要调用的函数的地址。如果指定SIG_IGN则忽略此信号,如果指定SIG_DEF则表示接收此信号后的动作是系统默认动作。当指定函数,则接到信号执行相应函数。此函数称为信号处理程序或者信号捕捉函数。

函数返回为一个函数指针,即返回函数指针的函数。返回的函数也有一个int参数,该参数为信号,返回的函数无返回值。因此信号处理程序都是只有一个int参数且无返回值的函数。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include"apue.h"
static void sig_usr(int);

int main(void)
{
if(signal(SIGUSR1, sig_usr) == SIG_ERR)
{
err_sys("signal error");
}

if(signal(SIGUSR2, sig_usr) == SIG_ERR)
{
err_sys("signal error");
}
while(1){
pause();
}
}

static void sig_usr(int signo)
{
if(signo == SIGUSR1)
{
printf("received SIGUSR1\n");
}

else if(signo == SIGUSR2)
{
printf("received SIGUSR2\n");
}

else {
err_dump("received signal %d\n", signo);
}


}

程序执行:

1
2
3
4
5
6
7
8
9
10
$ ./signal.o & //后台执行
[1] 8011
$ kill -USR1 8011 //发送SIGUSR1
received SIGUSR1
$ kill -USR2 8011 //发送SIGUSR2
received SIGUSR2
$ kill 8011 //发送SIGTERM
$ kill -USR2 8011
bash: kill: (8011) - No such process
[1]+ Terminated ./signal.o

程序启动

exec函数将原先设置为要捕捉的信号都恢复为默认动作,其它信号则不变,这是因为当执行exec后,原来捕捉函数的地址可能对于新程序来说是无意义的。

signal存在的一个限制:不改变信号的处理方式就不知道当前其处理方式。

进程创建

当一个进程调用fork时,其子进程继承父进程的信号处理方式,由于子进程复制了父进程内存镜像,所以捕捉函数的地址在子进程中也是有意义的。

不可靠信号

在早期的UNIX版本中,信号是不可靠的。不可靠是指,信号可能丢失:一个信号发生了,但进程却可能一直不知道这一点。

早期的另一个问题是,再进程每次接到信号对其进行处理时,随即将信号的动作重置为默认值。因此早期处理中断的代码中可能是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
int sig_int();
.
.
.
signal(SIGINT, sig_int);

sig_int()
{
signal(SIGINT, sig_int);
.
.
.
}

这存在两个问题。1:在第一个信号发生进行处理,和再重新设置捕捉函数之间如果再次出现该信号,则处理的方式是按默认值,可能与我们期望不一致。2:对于表示默认忽略的信号,如果我们希望设置为忽略是无法实现的,只能在捕捉函数中进行忽略。

中断的系统调用

早期的UNIX系统的一个特征是:如果执行一个低速系统调用而阻塞期间捕捉到一个信号,则系统调用就中断不再继续执行。该系统调用返回出错,其errno设置为EINTR。注意,这里是内核中的系统调用中断

系统调用分为两类:低速系统调用和其他系统调用。低速系统调用是可能会使进程永远阻塞的一类系统调用:

  1. 如果某些类型文件(如读管道、终端设备和网路设备)的数据不存在,则读操作可能会使调用者永远阻塞。
  2. 如果这些数据不能被相同类型的文件立即接受,则写操作可能会使调用者永远阻塞。
  3. 在某些条件发生之前打开某些文件,可能会发生阻塞(例如打开一个终端设备,需要先等待与之连接的调制解调器应答)。
  4. pause函数(使进程休眠直至捕捉到一个函数)和wait函数。
  5. 某些ioct1操作。
  6. 某些进程间通信函数。

我们必须显示的处理出错返回。如:存在一个读操作,它被中断,我们希望从新启动它,则可能是如下代码:

1
2
3
4
5
6
7
8
again:
if((n=read(fd, buf, BUFFSIZE))<0)
{
if(errno == EINTR)
{
goto again;
}
}

4.2BSD引进了某些系统调用的自动启动。自动启动的系统调用包括:ioct1readreadvwritewritevwaitwaitpid。但这也是有问题的,某些程序并不希望这些函数被中断后重新启动。需要注意的是,不同系统实现是不一样的,别的系统并不一定有自动重启。在我的Ubuntu18.04上read是可以自动重启的。

可重入信号

进程捕捉信号并对其进行处理时,进程正在执行的指令序列就被信号处理程序临时中断,它首先执行该信号处理程序中的指令。如果从信号处理程序返回(例如没有调用exitlongjmp),则继续执行在捕捉到信号时进程正在执行的正常指令序列。这时会有两个问题,1:如果被中断的进程正在执行malloc在堆中分配空间,而调用的信号捕捉函数内部也调用了malloc分配空间,则此时可能对进程造成破坏,因为malloc通常为它所分配的存储器维护一个链表,而插入执行信号处理程序时,进程可能正在更改此表。2:若中断的进程正在执行getpwnam这种将其结果存放在静态存储单元中的函数,在其插入的信号捕捉函数中又调用此函数,则正常调用信息可能被信号处理函数的结果覆盖。

下面列出来的函数是不会发生写情况,这些函数是可重入的并被称为是异步信号安全的。除了可重入外,在处理信号期间,它会阻塞任何引起不一致的信号发送。

可重入函数

不在上图中的,一般都是不可重入的,他们一般是(a):已知他们使用静态数据结构。(b):他们调用mallocfree。(c):他们是标准I/O。

由于每个线程只有一个errno变量,所以信号处理函数可能会更改其原来的值。因此,作为一个通用规则,当在信号处理程序中调用上图中的函数,应该先保存errno,在调用后恢复errno

非可重入例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include"apue.h"
#include<pwd.h>

static void my_alarm(int signo)
{
passwd *rootptr;
printf("in signal handler\n");
if((rootptr=getpwnam("root")) == NULL)
{
err_sys("getpwnam(root) error");
}
}
int main(void)
{
passwd *ptr;
signal(SIGALRM, my_alarm);
alarm(1);
while(1)
{
if((ptr=getpwnam("chst")) == NULL)
{
err_sys("getpwnam error");
}
if(strcmp(ptr->pw_name, "chst") != 0)
{
printf("return value corrupted!, pw_name=%s\n",ptr->pw_name);
}
}
}

SIGCLD语义

SIGCLDSIGCHLD两个信号很容易混淆。SIGCLDSystem V的一个信号。其与SIGCHLD不同。

SIGCLD早期处理方式是:

(1)如果进程明确地将信号的配置设置为SIG_IGN,则调用进程将不产生僵死进程。这里与默认动作(SIG_DFL)忽略不同。子进程在终止时,将其状态丢弃。如果调用进程随后调用一个wait函数,则会等待到所以子进程都终止,然后返回-1。并将其errno设置为ECHILD

(2)如果将SIGCLD设置为捕捉,则内核检查是否有子进程准备好被等待,如果是这样则调用SIGCLD处理程序。这里是应该是一个漏洞,在后面的例子可以看出来,应该是出现该信号时才调用此信号处理函数。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include"apue.h"
#include<sys/wait.h>

static void sig_cld(int);

int main()
{
pid_t pid;
if(signal(SIGCLD, sig_cld) == SIG_ERR)
{
perror("signal error");
}

if((pid = fork())<0)
{
perror("fork error");
}
else{
if(pid==0)
{
sleep(2);
_exit(0);
}
}
pause();
exit(0);
}

static void sig_cld(int signo)
{
pid_t pid;
int status;
printf("SIGCLD received\n");

if(signal(SIGCLD, sig_cld) == SIG_ERR)
{
perror("signal error");
}

if((pid = wait(&status)) < 0)
{
perror("wait error");
}

printf("pid = %d\n", pid);

}

该程序存在的问题是,在旧的UNIX系统上,信号处理程序使用一次就会被重置为默认处理方式,因此在信号处理函数中要再次绑定,但是绑定的位置放到了wait函数之前,此时内核会检查是否存在一个需要等待的子进程,而这时,wait还没被调用,子进程状态并未被释放,条件满足,于是会立即再次调用信号处理函数,这样就会不断迭代调用,知道达到资源限制。解决方法是,将wait函数放到重新绑定信号处理函数之前。这个问题在较新的系统上已经不存在了,一方面,现在的系统不会调用一次信号处理函数就将其恢复为默认处理方式,因此不用重新绑定,再次,现在都是检测函数是否出现,而不是检测是否有需要等待的进程。因此在我的电脑上执行结果为:

1
2
3
$ ./sigcld.o
SIGCLD received
pid = 13604

可靠信号术语和语义

当造成信号的事件发生时,未进程产生(generation)一个信号。当一个信号产生时,内核通常在进程表中以某种形式设置一个标志。

当对信号采取了这种动作时,我们说向进程递送(delivery)了一个信号。在信号产生和递送之间的时间间隔内,称信号是未决的(pending)

进程可以选用“阻塞信号递送”。如果一个进程产生了一个阻塞的信号,而且对该信号的动作是系统默认动作或者捕捉该信号,则为该进程将此信号保持为未决状态,直到该进程对此信号解除阻塞或者设置此信号的动作为忽略。

如果对一个信号解除阻塞前,该信号发了多次,如果递送该信号多次,则称这些信号进行了排队。除非支持POSIX.1实时扩展,否则大部分UNIX并不多信号排队而仅递送一次。

如果有多个信号要递送给一个进程,POXIS.1并未规定这些信号的递送顺序。但建议是在其他信号之前递送与进程当前状态有关的信号。

每个进程都有一个信号屏蔽字,它规定了当前要阻塞递送到该进程的信号集。对于每一种可能的信号,该屏蔽字都有一位与之对应,如果该位被设置,则对应的信号应该是阻塞的。

信号编号可能会超过一个整型所包含的二进制位数,因此POSIX.1定义了一个新数据类型sigset_t,它容纳一个信号集。

函数kill和raise

kill函数将信号发送到指定进程或进程组。raise函数则允许进程向自生发送信号:

1
2
3
4
5
6
7
#include<signal.h>

int kill(pid_t pid, int signo);

int raise(int signo);

//成功返回0,否则返回-1

调用raise(signo)等同于调用kill(gitpid(),signo)

kill的参数有以下四种情况:

  1. pid > 0: 将该信号发送给进程ID为pid的进程。
  2. pid == 0:将信号发送给发送进程同属一个进程组的所以进程。而且发送进程有权限向其发送信号的所以进程。
  3. pid < 0:将信号发送给进程组ID为pid绝对值的进程组。而且发送进程有权限向其发送信号的所以进程。
  4. pid == -1:将该信号发送到发送进程有权限向他们发送信号的所以进程。

进程将信号发送给其它进程需要权限。超级用户可以将信号发送任一进程。对于非超级用户,其基本规则为是:发送者的实际用户ID和有效用户ID必须等于接收者的实际用户ID或有效用户ID。

POSIX.1将信号编号为0定义为空信号,signo如果是0,则kill仍执行正常的错误检查,但不发生信号。常用来检查特定进程是否依然存在。如果一个不存在的进程发送信号,则kill返回-1,errno被设置为ESRCH

测试进程存在不是原子操作。在kill向调用者返回结果时,原来存在的进程可能已经终止了。

如果kill为调用者产生信号,而且此信号是不被阻塞的,那么在kill返回之前,signo或者某个其他未决的、非阻塞信号被传送至该进程。即如果进程向自身发送SIGKILL信号,则在返回之前进程已经终止了。

函数alarm和pause

使用alarm函数可以设置一个定时器(闹钟时间),在某个时刻该定时器会超时。当定时器超时时,产生SIGALRM信号,如果忽略或不捕捉该信号,进程终止。

1
2
3
4
5
#include<unistd.h>

unsigned int alarm(unsigned int seconds);

//返回值,0或以前设置的闹钟时间的余留秒数。

每个进程只能有一个闹钟时间。如果调用alarm时,之前已经为该进程注册的闹钟时间还没有超时,则闹钟时间会被新值替代,而旧的剩余时间会被返回。

如果有以前注册的尚未超时的闹钟时间,而且本次调用的second值为0,则取消之前的闹钟时间,其剩余时间作为返回值。

pause函数使调用进程挂起直至捕捉到一个信号:

1
2
3
4
#include<unistd.h>

int pause(void);
//返回值:-1, errno设置为EINTR

注意:只有处理了一个信号处理程序并从其返回时,pause才返回。因此,如果被捕捉的函数执行耗时很长,将一值阻塞。在这种情况下,pause返回-1,errno设置为EINTR

使用alarmpause实现sleep

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

static void sig_alarm(int signo)
{
//什么都不用做,只是为了从pause唤醒进程
}

unsigned int sleep1(unsigned int seconds)
{
if(signal(SIGALRM, sig_alarm) == SIG_ERR)
{
return seconds;
}
alarm(seconds);
pause();
return alarm(0);
}

程序存在三个问题:

  1. 如果在调用sleep1之前已经设置了闹钟,则会被sleep1中重设删除。处理方式:检查第一次调用alarm的返回值,如果小于seconds,则只等到之前设置的闹钟超时,如果返回值大于seconds,则应该在sleep1返回之前重置闹钟,使原来的闹钟不会被清除。
  2. 该程序修改了SIGALRM的配置,如果编写了一个函数供其他函数调用,则在函数被调用时应该先保留原来的配置(sleep1signal的返回),在该函数返回前恢复配置。
  3. 调用alarmpause之间存在竞争条件。可能alarm在调用pause之前超时,此时,调用者可能被永久挂起。

前两个问题解决比较简单,对于第三个问题的解决需要后面学习。

使用setjmplongjmp解决第三个问题:

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

static jmp_buf env_alrm;

static void sig_alarm(int signo)
{
longjmp(env_alrm, 1);
}

unsigned int sleep1(unsigned int seconds)
{
if(signal(SIGALRM, sig_alarm) == SIG_ERR)
{
return seconds;
}
if(setjmp(env_alrm) == 0)
{
alarm(seconds);
pause();
}
return alarm(0);
}

该函数基本解决第三个问题,但会存在新的问题:如果SIGALRM中断了某个其他信号的处理程序,则调用longjmp将会提早终止该信号处理程序。如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include<setjmp.h>
#include<unistd.h>
#include<signal.h>
#include"apue.h"

static jmp_buf env_alrm;

static void sig_alarm(int signo)
{
longjmp(env_alrm, 1);
}

unsigned int sleep2(unsigned int seconds)
{
if(signal(SIGALRM, sig_alarm) == SIG_ERR)
{
return seconds;
}
if(setjmp(env_alrm) == 0)
{
alarm(seconds);
pause();
}
return alarm(0);
}

static void sig_int(int signo)
{
int i,j;
volatile int k;
printf("\nsig_int starting\n");
for(i = 0;i<300000; i++)
{
for(j=0;j<4000;j++)
{
k += i*j;
}
}

printf("sig_int finish\n");
}

int main()
{
unsigned int unslept;
if(signal(SIGINT, sig_int) == SIG_ERR)
{
err_sys("signal(SIGINT) error");
}
unslept = sleep2(5);
printf("sleep2 return :%u\n", unslept);
exit(0);
}

执行结果:

1
2
3
4
5
6
7
8
9
$ ./sleep.o
^C
sig_int starting
sig_int finish
sleep2 return :1
$ ./sleep.o
^C
sig_int starting
sleep2 return :0

两次执行差异,第一次执行后,直接按Ctrl+C。第二次,执行程序后过一会再按Ctrl+C。解释:对于第一次来说,直接按下Ctrl+C会执行sig_int函数,而且在alarm到达之前就执行完了,于是pause返回,进程继续执行,由于alarm还未超时,此时调用alarm(0)会返回上一次(5)设置的时间的剩余时间,这里我的运行结果是还剩下1秒。对于第二次来说,过一阵按Ctrl+C时,在执行sig_int时,alarm设置的5秒超时,于是暂停执行sig_int函数,执行sig_alarm函数,这时,由于调用了longjmp,因此sig_int将不会再执行了,就造成了提前终止了SIGINT信号的处理程序。在执行完sig_int函数后,调用alarm(0),由于上一个设置的闹钟已经执行完成,因此返回是0。

使用alarmsetjmp对可能阻塞的操作,设置时间上限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include"apue.h"
#include<setjmp.h>

static void sig_alarm(int);
static jmp_buf env_alarm;

int main()
{
int n;
char line[MAXLINE];
if(signal(SIGALRM, sig_alarm) == SIG_ERR)
{
err_sys("signal(SIGALRM) error");
}

if(setjmp(env_alarm)!=0)
{
err_quit("read timeout");
}

alarm(5);

if((n=read(STDIN_FILENO, line, MAXLINE))<0)
{
err_sys("read error");
}
alarm(0);

write(STDOUT_FILENO, line, n);
exit(0);
}

static void sig_alarm(int signo)
{
longjmp(env_alarm, 1);
}

该程序也存在问题:如果SIGALRM中断了某个其他信号的处理程序,则调用longjmp将会提早终止该信号处理程序。

信号集

我们需要一个能够表示多个信号:信号集的数据类型。POSIX.1定义数据类型sigset_t以包含一个信号集,并定义了下面5个处理信号集的函数:

1
2
3
4
5
6
7
8
9
10
#include<signal.h>

int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
//四个函数,成功返回0, 否则返回-1。

int sigismember(const sigset_t *set, int signo);
//真返回1, 否则返回0

函数sigemptyset初始化由set指向的信号集,清除其中所以信号。函数sigfillset初始化由set指向的信号集,使其包含所以信号。所以程序在使用信号集之前都必须调用两个函数中的至少一个。

函数sigprocmask

进程的信号屏蔽字规定了当前阻塞而不传递给该进程的信号集。调用函数sigprocmask可以检测和修改,或同时进行检测和修改:

1
2
3
#include<signal.h>
int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
//成功返回0,出错返回-1

首先,若oset是非空指针,那么进程的当前信号屏蔽字通oset返回。

其次,若set是非空指针,则参数how决定如何修改当前信号屏蔽字。下表说明了可选参数和含义:

how 说明
SIG_BLOCK 进程的信号屏蔽字是当前进程信号屏蔽字和set指向信号集的并集,set包含了希望阻塞附加信号。
SIG_UNBLOCK 进程的信号屏蔽字是当前进程信号屏蔽字和set指向信号集补集的交集,set包含了希望解除阻塞的信号。
SIG_SETMASK 进程新的信号屏蔽字是set指向的值。

在调用sigprocmask后如果有任何未决的、不在阻塞的信号,则在sigprocmask返回之前,至少将其中之一递送给该进程。

函数sigpending

sigpending函数返回当前进程中阻塞的,未递送的信号(已经产生了):

1
2
3
4
#include<signal.h>

int sigpending(sigset_t *set);
//成功返回0,否则返回-1

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include"apue.h"
static void sig_quit(int);

int main()
{
sigset_t newmask, oldmask, pendmask;
if(signal(SIGQUIT, sig_quit) == SIG_ERR)
{
err_sys("can't catch SIGQUIT");
}
sigemptyset(&newmask);
sigaddset(&newmask, SIGQUIT);
if(sigprocmask(SIG_BLOCK, &newmask, &oldmask)<0)
{
err_sys("SIG_BLOCK error");
}
sleep(5);
if(sigpending(&pendmask)<0)
{
err_sys("sigpending error");
}

if(sigismember(&pendmask, SIGQUIT))
{
printf("\nSIGQUIT pending\n");
}

if(sigprocmask(SIG_SETMASK, &oldmask, NULL)<0)
{
err_sys("SIG_SETMARK error");
}
printf("SIGQUIT unblock\n");
sleep(5);
exit(0);
}

static void sig_quit(int signo)
{
printf("catch SIGQUIT\n");
if(signal(SIGQUIT, SIG_DFL)==SIG_ERR)
{
err_sys("signal error");
}
}

执行:

1
2
3
4
5
6
$ ./sigpending.o 
^\^\^\^\^\^\
SIGQUIT pending
catch SIGQUIT
SIGQUIT unblock
^\Quit (core dumped)

函数sigaction

sigaction函数用来检查或修改与指定信号相关联的处理动作:

1
2
3
#include<signal.h>

int sigaction(int signo, const struct sigaction *restrict act, struct sigaction *restrict oact);

signo是信号编号。若act指针为空,则要修改其动作,若oact不为空,则系统由oact返回该信号的上一个动作。其中结构体为:

1
2
3
4
5
6
struct{
void (*sa_handler)(int); //信号处理函数地址或者SIG_DEL/SIG_IGN
sigset_t sa_mask; //添加的阻塞信号
int sa_flags; //信号选择
void (*sa_sigaction)(int,siginfo_t *,void *);//
}

sa_mask字段说明了一个信号集,在调用(进入)该信号捕捉函数之前,这一信号集要加入到进程的信号屏蔽字中。仅当从捕捉函数中返回时,再将进程的信号屏蔽字恢复为原值。这样就可以在执行捕捉函数时阻塞某些信号。在一个信号处理程序被调用时,操作系统建立的新信号屏蔽字包括正在被递送的信号。因此保证在处理一个信号时,如果该信号再次发生,那么会阻塞到对前一个信号的处理结束。若同一个信号多次发生,通常并不会将他们加入队列,所以如果在某种信号被阻塞是,若发生了多次,那么对信号解除阻塞后,其信号处理函数只会被调用一次。

act结果的sa_flags字段指定对信号进行处理的各个选项:

选项 说明
SA_INTERRUP 由此信号中断的系统调用不自动重启动。(sigaction默认处理方式)
SA_NOCLDSTOP signoSIGCHLD,当子进程停止是,不产生此信号。当子进程终止时,仍旧产生信号。
SA_NODEFER signoSIGCHLD时,子进程终止时,不创建僵死进程。如调用进程随后调用wait,则阻塞到所以子进程终止,返回-1.errno设置为ECHLD
SA_ONSTACT 当捕捉到该信号时,在执行其信号捕捉函数时,系统不自动阻塞此信号(除非sa_mark包含了此信号)
SA_ONSTACK
SA_RESETHAND
SA_RESTART 由此信号中断的系统调用自动重启动。
SA_SIGINFO 对信号处理程序提供了一个附加信息:一个指向siginfo结构的指针以及一个指向进程上下文标识符的指针。

sa_sigaction字段是一个替代的信号处理程序,在sigaction的结构中使用了SA_SIGINFO标志时,使用该信号处理程序。

siginfo包含了信号产生的原因的有关信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct siginfo
{
int si_signo; //信号编号
int si_erron; //错误编号
int si_code;//
pid_t si_pid; //发送进程ID
uid_t si_uid; //发送进程实际用户ID
void *si_addr; //导致错误地址
int si_status; //
union sigval si_value; //
};

sigval联合包含下列字段
int sival_int;
void *sival_ptr;

​ 应用程序在si_value.sival_int中传递一个整数或者在si_value.sigval_ptr中传递一个指针。

​ 下图展示了各种信号的si_code:

si_code

​ 若信号是SIGCHLD,则设置si_pidsi_statussi_uid字段。若信号是SIGBUSSIGILLSIGFPESIGSEGC,则si_addr包含故障的根地址。

​ 信号处理程序的context参数是无类型指针,它可以被强制转换成ucontext_t结构类型,该结构标识信号传递时进程的上下文。至少包含下面字段:

1
2
3
4
5
6
7
8
9
ucontext_t *uc_link;//
sigset_t un_sigmask;//
stack_t un_stack; //
mcontext_t un_mcontext;

// uc_satck字段描述了当前上下文使用的栈,至少包含下列成员
void *ss_sp;
size_t ss_size;
int ss_flags;

使用sigaction实现signal函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include"apue.h"
/* typedef void Sigfunc(int);*/
Sigfunc *signal(int signo, Sigfunc *func)
{
struct sigaction act, oact;
act.sa_handler = func;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
if(signo == SIGALRM)
{
#ifdef SA_INTERRUPT
act.sa_flags |= SA_INTERRUPT
#endif
}
else{
act.sa_flags |= SA_RESTART;
}

if(sigaction(signo, &act, &oact)<0)
{
return SIG_ERR;
}
return oact.sa_handler;
}

函数sigsetjmp和siglongjmp

​ 在执行信号处理程序时,对应信号会被自动加入到信号屏蔽字中,此时如果调用longset函数,对于该信号是否从信号屏蔽字中恢复是未指定的,而是定义了sigsetjmpsiglongjmp函数来指定这种操作:

1
2
3
4
5
#inclue<setjmp.h>

int sigsetjmp(sigjmp_buf env, int savemask);
//直接调用返回0,从siglongjmp调用返回非0
void siglongjmp(sigjmp_buf env, int val);

​ 这两个函数和setjmplongjmp的唯一区别是sigsetjmp增加了一个参数。如果savemask非0,则sigsetjmpenv中保存进程的当前信号屏蔽字。调用siglongjmp时,如果带非0savemarksigsetjmp调用已经保存了env,则siglongjmp从其中恢复保存的信号屏蔽字。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#include"apue.h"
#include<setjmp.h>
#include<time.h>

static void sig_usr1(int);
static void sig_alrm(int);
static sigjmp_buf jmpbuf;
static volatile sig_atomic_t canjmp;
void pr_mask(const char *str)
{
sigset_t sigset;
int errno_save;
errno_save = errno;
if(sigprocmask(0, NULL, &sigset)<0)
{
err_ret("sigprocmask error");
}
else{
printf("%s", str);
if(sigismember(&sigset, SIGINT))
{
printf(" SIGINT");
}
if(sigismember(&sigset, SIGQUIT))
{
printf(" SIGQUIT");
}
if(sigismember(&sigset, SIGUSR1))
{
printf(" SIGUSR1");
}
if(sigismember(&sigset, SIGALRM))
{
printf(" SIGALRM");
}
printf("\n");
}
errno = errno_save;
}

int main()
{
if(signal(SIGUSR1, sig_usr1) == SIG_ERR)
{
err_sys("signal(SIGUSR1) error");
}
if(signal(SIGALRM, sig_alrm) == SIG_ERR)
{
err_sys("signal(SIGALRM) error");
}
pr_mask("starting main: ");

if(sigsetjmp(jmpbuf, 1))
{
pr_mask("ending main: ");
exit(0);
}

canjmp = 1;
while(1){
pause();
}

}

static void sig_usr1(int signo)
{
time_t starttime;
if(canjmp == 0)
{
return;
}

pr_mask("starting sig_usr1: ");

alarm(3);
starttime = time(NULL);
while(1)
{
if(time(NULL) > starttime +5)
{
break;
}
}

pr_mask("finish sig_usr1: ");

canjmp = 0;
siglongjmp(jmpbuf, 1);
}

static void sig_alrm(int signo)
{
pr_mask("in sig_alrm: ");
}

执行:

1
2
3
4
5
6
7
8
9
10
11
$ ./sigsetjmp.o &
[1] 6705
$ starting main:

$ kill -USR1 6705
starting sig_usr1: SIGUSR1
$ in sig_alrm: SIGUSR1 SIGALRM
finish sig_usr1: SIGUSR1
ending main:

[1]+ Done ./sigsetjmp.o

函数sigsuspend

sigsuspend函数是一个原子操作,该函数的作用是,先恢复信号屏蔽字,然后使进程休眠:

1
2
3
#include<signal.h>
int sigsuspend(const sigset_t *sigmask);
//返回-1,并将errno设置为EINTR

​ 进程的信号屏蔽字设置为sigmask指向的值。在捕捉到一个信号或发生了一个会终止该进程的信号之前,该进程被挂起。如果捕捉到一个信号而且从该信号处理程序返回,则sigsuspend返回,并且该进程的信号屏蔽字设置为调用sigsuspend之前的值。

例程:捕捉中断信号和退出信号,但只有当是退出信号时时才唤醒进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include"apue.h"

volatile sig_atomic_t quitflag = 0;

static void sig_int(int signo)
{
if(signo == SIGINT)
{
printf("\ninterrupt\n");
}
else{
quitflag = 1;
}
}

int main()
{
sigset_t newmask,zeromask,oldmask;
if(signal(SIGINT, sig_int) == SIG_ERR)
{
err_sys("signal error");
}
if(signal(SIGQUIT, sig_int)==SIG_ERR)
{
err_sys("signal error");
}

sigemptyset(&newmask);
sigemptyset(&zeromask);
sigaddset(&newmask, SIGQUIT);

if(sigprocmask(SIG_BLOCK, &newmask,&oldmask)<0)
{
err_sys("SIG_BOCK error");
}

while(quitflag == 0)
{
sigsuspend(&zeromask);
}

quitflag = 0;

if(sigprocmask(SIG_SETMASK, &oldmask, NULL)<0)
{
err_sys("SIG_SETMASK error");
}
exit(0);
}

执行:

1
2
3
4
5
6
7
8
9
10
$ ./sigsuspend1.o 
^C
interrupt
^C
interrupt
^C
interrupt
^C
interrupt
^\$

考虑在第八章中,竞争条件的例程,其中我们使用了TELL_**WAIT_**。这里我们可以使用信号来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include"apue.h"

static void charatatime(char *);

static sigset_t newmask, oldmask;
static volatile sig_atomic_t sigflags = 0;

static void sig_usr(int signo)
{
sigflags = 1;
}

void TELL_WAIT()
{
sigemptyset(&newmask);
sigaddset(&newmask, SIGUSR1);
sigaddset(&newmask, SIGUSR2);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
if(signal(SIGUSR1, sig_usr)==SIG_ERR)
{
err_sys("siganl error");
}
if(signal(SIGUSR2, sig_usr)==SIG_ERR)
{
err_sys("siganl error");
}
}

void WAIT_PARENT()
{
sigset_t zeromask;
sigemptyset(&zeromask);
while(sigflags == 0)
{
sigsuspend(&zeromask);
}
sigflags = 0;
}

void TELL_CHILD(pid_t pid)
{
kill(pid, SIGUSR2);
}

int main()
{
pid_t pid;

TELL_WAIT();

if((pid=fork())<0)
{
printf("fork error\n");
}
else{
if(pid==0)
{
WAIT_PARENT();
charatatime("output from child\n");
}
else{
charatatime("output from parent\n");
TELL_CHILD(pid);
}
}
exit(0);
}

static void charatatime(char *str)
{
char *ptr;
int c;
setbuf(stdout, NULL);//设置标准输出无缓冲,更方便看到竞争
for(ptr=str;(c=*ptr++)!=0;)
{
putc(c,stdout);
}
}

函数abort

abort函数使程序异常终止:

1
2
#include<stdlib.h>
void abort(void);

​ 其方法是调用raise(SIGABRT)函数。

​ 让进程捕捉SIGABRT的意图是:在进程终止之前由其执行所需清理操作。如果进程并不在信号处理程序中终止自己,POSIX.1申明当信号处理程序返回时,abort终止进程。

POSIX.1中abort的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include<signal.h>
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>

void abort(void)
{
sigset_t mask;
struct sigaction action;
sigaction(SIGABRT, NULL, &action);
if(action.sa_handler == SIG_IGN)
{
action.sa_handler = SIG_DFL;
sigaction(SIGABRT, &action, NULL);
}
if(action.sa_handler == SIG_DFL)
{
fflush(NULL);
}
sigfillset(&mask);
sigdelset(&mask, SIGABRT);
sigprocmask(SIG_SETMASK, &mask, NULL);
kill(getpid(), SIGABRT);

fflush(NULL);
action.sa_handler = SIG_DFL;
sigaction(SIGABRT, &action, NULL);
sigprocmask(SIG_SETMASK, &mask, NULL);
kill(getpid(), SIGABRT);
exit(0);

函数system

​ POSIX.1要求system或略SIGINTSIGQUIT,阻塞SIGCHLD。对其原因解释的部分没看看明白。实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include<sys/wait.h>
#include<errno.h>
#include<unistd.h>
#include<signal.h>

int system(const char *cmdstring)
{
pid_t pid;
int status;
struct sigaction ignore, saveintr, savequit;
sigset_t chldmask, savemask;
if(cmdstring == NULL)
{
return 1;
}

//忽略SIGQUIT SIGINT信号
ignore.sa_handler = SIG_IGN;
sigemptyset(&ignore.sa_mask);
ignore.sa_flags = 0;
if(sigaction(SIGINT, &ignore, &saveintr)<0)
{
return -1;
}

if(sigaction(SIGQUIT, &ignore, &saveintr)<0)
{
return -1;
}

// 阻塞SIGCHLD
sigemptyset(&chldmask);
sigaddset(&chldmask, SIGCHLD);
if(sigprocmask(SIG_BLOCK, &chldmask, &savemask)<0)
{
return -1;
}


if((pid = fork())<0)
{
status = -1;
}
else{
if(pid==0)
{
//子进程中恢复各个信号的处理
sigaction(SIGINT, &saveintr, NULL);
sigaction(SIGQUIT, &savequit, NULL);
sigprocmask(SIG_SETMASK, &savemask, NULL);

execl("/bin/sh", "sh", "-c", cmdstring, (char*)0); //如果程序正常执行,则会自己调用exit函数,否则使用下一条命令。
_exit(127);
}
else{
while (waitpid(pid, &status, 0)<0)
{
if(errno != EINTR)
{
status = -1;
break;
}
}
}
}

//恢复父进程的三个信号处理
if(sigaction(SIGQUIT, &savequit, NULL)<0)
{
return -1;
}
if(sigaction(SIGINT, &saveintr, NULL)<0)
{
return -1;
}
if(sigprocmask(SIG_SETMASK, &savequit, NULL)<0)
{
return -1;
}
return status;
}

system返回值为shell终止状态。对于由于信号而终止的情况,终止状态为信号编号加上128。

函数sleepnanosleepclock_nanosleep

1
2
3
#include<unistd.h>
unsigned int sleep(unsigned int seconds);
//返回0或未休眠完的秒数。

​ 此函数将进程挂起,直到满足下面条件中的一个:

​ (1):过了seconds设置的墙上时钟时间。

​ (2):调用进程捕捉到了一个信号并从信号处理程序返回。

​ 在第一中情形下,返回值是0,当由于捕捉到某个信号而提早返回时,返回值是未休眠完的秒数。由于其他系统活动(调用信号处理程序花费的时间),实际返回时间比要求要迟一些。

POSIX.1中的sleep的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include"apue.h"

static void sig_alrm(int signo)
{
//什么都不用做,只是用来唤醒进程
}

unsigned int sleep(unsigned int seconds)
{
struct sigaction newact, oldact;
sigset_t newmask, oldmask,suspmask;
unsigned int unslept;

//设置处理函数,保存原信息
newact.sa_handler = sig_alrm;
sigemptyset(&newact.sa_mask);
newact.sa_flags = 0;
sigaction(SIGALRM, &newact, &oldact);

//阻塞SIGALRM并保存当前信号屏蔽字
sigemptyset(&newmask);
sigaddset(&newmask, SIGALRM);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);

alarm(seconds);

suspmask = oldmask;

//确保SIGALRM不被阻塞
sigdelset(&suspmask, SIGALRM);

//等待任何信号被捕获
sigsuspend(&suspmask);

//当有信号被捕获后继续执行

unslept = alarm(0);

//恢复之前捕获函数
sigaction(SIGALRM, &oldact, NULL);

//恢复原来的信号屏蔽字
sigprocmask(SIG_SETMASK, &oldmask, NULL);
return unslept;
}

nanosleepsleep函数类似,但提供了纳秒级的精度:

1
2
3
#include<time.h>
int nanosleep(const struct timespce *reqtp, struct timespec *remtp);
//休眠到指定时间返回0,否则返回-1

reqtp指定休眠时间,提前返回时remtp返回剩余时间。

​ 多系统时钟的引入,需要使用相对于特定时钟的延迟时间来挂起调用线程。clock_nanosleep提供了这种功能:

1
2
3
#include<time.h>
int clock_nanosleep(clockid_t clock_id, int flags, const struct timespec *reqtp, struct timespec *remtp);
//达到休眠时间返回0,若出错,返回错误码

clock_id指定了计算延迟时间基于的时钟(6.10节)。flags控制延迟时间是相对还是绝对:0是相对时间(希望休眠时长),TIMER_ABSTIME是绝对(希望休眠到何时)。剩下两个参数与nanosleep一样。

函数sigqueue

​ 在POSIX.1的实时扩展中,有些系统已经开始支持信号排队。

​ 使用排队信号必须做一下几个操作:

  1. 使用sigaction函数安装信号处理装置时指定SA_SIGINFO标志。
  2. sigaction中的sa_sigaction成员中提供信号处理程序。
  3. 使用sigqueue函数发送信号。
1
2
3
#include<siganl.h>
int sigqueue(pid_t pid, int signo, const union sigval value);
//成功返回0,出错返回-1

作业控制信号

​ 六个与作业控制有关的信号:

  1. SIGCHLD:子进程停止或终止。

  2. SIGCONT:如果进程停止,使进程继续运行。

  3. SIGSTOP:停止信号(不能被捕捉或忽略)。

  4. SIGTSTP:交互式停止信号。

  5. SIGTTIN:后台进程组成员读控制终端。

  6. SIGTTOU:后台进程组成员写控制终端。

    ​ 当键入挂起字符(Ctrl+Z)时,SIGTSTP被送至前台进程组的所以进程。如果进程是停止的,则SIGCONT的默认动作是继续该进程,否则忽略该信号。当对一个停止的进程产生一个SIGCONT信号时,该进程就继续,即使该信号是阻塞或忽略。

信号名和编号

​ 某些系统提供数组:

1
extern char *sys_siglist[];

可以使用psignal函数可移植地打印以信号编号对于的字符串:

1
2
#include<signal.h>
void psignal(int signo, const char*msg);

​ 字符串msg(通常是程序名)输出到标准错误文件,后面跟随一个冒号和一个空格,再后面对该信号的说明,最后一个换行符。

​ 如果在sigaction信号处理程序中有siginfo结构,可以使用psiginfo函数打印信号信息:

1
2
#include<signal.h>
void psiginfo(const siginfo_t *info, const char *msg);

​ 如果只需要信号的字符描述部分,不需要写到标准错误文件中,可以使用strsignal函数:

1
2
#include<signal.h>
char *strsignal(int signo);

习题

10.6:使用TELL_***WAIT_***写一个程序,父进程与子进程交替往一个文件中写入一个数和进程ID,数是递增的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#include"apue.h"
#include<stdio.h>
#include<fcntl.h>

static void charatatime(char *);

static sigset_t newmask, oldmask;
static volatile sig_atomic_t sigflags = 0;

static void sig_usr(int signo)
{
sigflags = 1;
}

void TELL_WAIT()
{
sigemptyset(&newmask);
sigaddset(&newmask, SIGUSR1);
sigaddset(&newmask, SIGUSR2);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
if(signal(SIGUSR1, sig_usr)==SIG_ERR)
{
err_sys("siganl error");
}
if(signal(SIGUSR2, sig_usr)==SIG_ERR)
{
err_sys("siganl error");
}
}

void WAIT_PARENT()
{
sigset_t zeromask;
sigemptyset(&zeromask);
while(sigflags == 0)
{
sigsuspend(&zeromask);
}
sigflags = 0;
if(sigprocmask(SIG_SETMASK, &oldmask, NULL)<0)
{
err_sys("set mask error\n");
}
}

void WAIT_CHILD()
{
sigset_t zeromask;
sigemptyset(&zeromask);
while(sigflags == 0)
{
sigsuspend(&zeromask);
}
sigflags = 0;
if(sigprocmask(SIG_SETMASK, &oldmask, NULL)<0)
{
err_sys("set mask error\n");
}
}

void TELL_CHILD(pid_t pid)
{
kill(pid, SIGUSR2);
}

void TELL_PARENT(pid_t pid)
{
kill(pid, SIGUSR1);
}

int main()
{
pid_t pid;

TELL_WAIT();

int num = 0;
FILE *fp;
if((fp =fopen("temp.txt", "r+b"))==NULL)
{
printf("fopen error\n");
return 0;
}
fprintf(fp, "num = %d, pid = %ld\n", num, (long)getpid());
fflush(fp);
if((pid=fork())<0)
{
printf("fork error\n");
}
else{
int start = 0;
while(num < 100)
{
if(pid==0)
{
if(start == 0)
{
num++;
start = 1;
}
else {
WAIT_PARENT();
num += 2;
}
fprintf(fp, "num = %d, pid = %ld\n", num, (long)getpid());
fflush(fp);
TELL_PARENT(getppid());
}
else{
WAIT_CHILD();
num += 2;
fprintf(fp, "num = %d, pid = %ld\n", num, (long)getpid());
fflush(fp);
TELL_CHILD(pid);
}
}
}
fclose(fp);
exit(0);
}

10_11:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include<stdio.h>
#include<unistd.h>
#include<sys/resource.h>
#include<fcntl.h>
#include<signal.h>
#include<stdlib.h>

static void sig_xfsz(int signo)
{
printf("rlimit of xdsz is too small\n");
exit(0);
}

int main()
{
char buf[100];
int n;
rlimit rtp;
rtp.rlim_cur = 1024;
rtp.rlim_max = RLIM_INFINITY;
if(signal(SIGXFSZ, sig_xfsz)==SIG_ERR)
{
perror("signal error\n");
return 0;
}
if(setrlimit(RLIMIT_FSIZE, &rtp)==0)
{
int cur = open("CMakeCache.txt", O_RDONLY);
int cp = creat("tp.txt", O_RDWR);
unlink("tp.txt");
while ((n=read(cur, buf, 100))>0)
{
if(write(cp, buf, n)!=n)
{
perror("write error\n");
}
else{
printf("%d\n", n);
}
}
close(cur);
close(cp);
}
else
{
perror("setrlimt error\n");
}
}

第十一章 线程

线程概念

多线程的好处:

  1. 通过为每种事件类型分配单独的处理线程,可以简化处理异步事件的代码。每个线程在进行事件处理时可以采用同步编程模式,这比异步编程模式简单很多。
  2. 多个进程需要使用操作系统提供的复杂机制才能实现内存和文件描述符的共享,而多线程自动共享存储空间和文件描述符。
  3. 有些问题可以分解从而提高整个程序的吞吐量。
  4. 交互的程序可以通过多线程来改善响应时间,多线程可以把处理用户输入输出的部分与其他部分分离。

每个线程都包含有执行环境所必须的信息,其中包括进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符。

线程标识

线程ID只在它所属的进程上下文中才有意义。线程ID使用pthread_t数据类型来表示。函数pthread_equal函数可以用来比较两个线程ID:

1
2
3
#include<pthread.h>
int pthread_equal(pthread_t tid1, pthread_t tid2);
//相等返回非0,否则返回0

调用pthread_self()可以获得自身的线程ID:

1
2
3
#include<pthread.h>
int pthread_self();
//返回自身线程ID

线程创建

函数pthread_create用来创建新的线程:

1
2
3
#include<pthread.h>
int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void *), void *restarict arg);
//成功返回0,否则返回错误编码

当成功返回时,新创建的线程ID会被设置为tidp指向的内存单元。attr参数用于定制各种不同的线程属性,具体会在第十二章讨论,使用NULL则是创建一个默认属性的线程。

新创建的线程从start_rtn函数的地址开始运行,该函数只有一个无类型指针参数arg。如果要向start_rtn传递的参数有一个以上,那么需要将参数放到一个结果中,将结果的地址作为arg参数传入。

pthread函数调用之后通常会返回错误码,这一点并不像其他POSIX函数一样设置errno。每个线程都提供errno的副本,这只是为了与使用errno的现有函数兼容。

例程:打印线程ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include<pthread.h>
#include<stdio.h>
#include<unistd.h>
#include"apue.h"

pthread_t ntid;

void printids(const char *s)
{
pid_t pid;
pthread_t tid;

pid = getpid();
tid = pthread_self();
printf("%s pid %lu tid %lu (0x%lx)\n", s, (unsigned long)pid, (unsigned long)tid, (unsigned long)tid);
}

void *thr_fn(void *arg)
{
printids("new thread: ");
return (void *)0;
}

int main(void)
{
int err;
err = pthread_create(&ntid, NULL, thr_fn, NULL);
if(err!=0)
{
err_exit(err, "can't create thread");
}
printids("main thread: ");
sleep(1);
exit(0);
}

由于pthread不是linux下默认的库,因此需要添加链接:

1
$ g++ a.cpp -o a.o -lapue -lthread

这里需要注意两个问题:第一,要让主线程sleep一秒,这是要等待新线程执行完毕,如果主线程返回了,而新线程还没执行完成,则整个进程返回,新线程将不会执行了。第二,在新线程中获取线程ID也要使用pthread_self而不能直接使用存储在ntid中的,这是因为不能保证在执行子线程时,函数pthread_create已经返回了,如果这时候直接使用ntid,则可能是未初始的内容。

在Linux下运行结果:

1
2
3
$ ./pthread1.o 
main thread: pid 9416 tid 140693008582464 (0x7ff5a4cc8740)
new thread: pid 9416 tid 140693000173312 (0x7ff5a44c3700)

线程终止

如果进程中的任意线程调用了exit_Exit或者_exit,那么整个进程就会终止。单个线程可以通过3种方式退出,可以在不终止整个进程的情况下,停止它的控制流:

  1. 线程可以简单的从启动例程中返回,返回值是线程的退出码(即返回的指针存储的内容)。

  2. 线程可以被同一进程中的其他线程取消。

  3. 线程调用pthread_exit

    1
    2
    #include<pthread.h>
    void pthread_exit(void *rval_ptr);

    rval_ptr参数是一个无类型参数指针,与传递给启动例程的单个参数类似。进程中的其他线程也可以通过调用pthread_join函数访问到这个指针:

    1
    2
    3
    #include<pthread.h>
    int pthread_join(pthread_t thread, void **reval_ptr);
    //成功返回0,否则返回错误编号

    调用线程将一直阻塞,直到指定的线程调用pthread_exit、从启动例程中返回或者被取消。如果是简单的从例程中返回,rval_ptr包含返回码。如果线程被取消,由rval_ptr指定的内存单元就设置为PTHREAD_CANCELED

    可以通过调用pthread_join自动将进程置于分离状态(随后讨论),这样资源就可以恢复。如果线程已经处于分离状态,pthread_join就会失败,返回EINVAL。对线程返回不感兴趣,可以将rval_ptr设置为NULL。此时调用pthread_join函数等待指定线程终止,不获取线程终止状态。

    例程:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    #include"apue.h"
    #include<pthread.h>

    void *thr_fn1(void *arg)
    {
    printf("thread 1 returning\n");
    return (void*)1;
    }

    void *thr_fn2(void *arg)
    {
    printf("thread 2 returning\n");
    pthread_exit((void*)2);
    }

    int main(void)
    {
    int err;
    pthread_t tid1, tid2;
    void *tret;
    err = pthread_create(&tid1, NULL, thr_fn1, NULL);
    if(err!=0)
    {
    err_exit(err, "can't create thread 1");
    }
    err = pthread_create(&tid2, NULL, thr_fn2, NULL);
    if(err!=0)
    {
    err_exit(err, "can't create thread 1");
    }
    err = pthread_join(tid1, &tret);
    if(err!=0)
    {
    err_exit(err, "can't join with thread 1");
    }
    printf("thread 1 exit code %ld\n", (long)tret);
    err = pthread_join(tid2, &tret);
    if(err!=0)
    {
    err_exit(err, "can't join with thread 2");
    }
    printf("thread 2 exit code %ld\n", (long)tret);
    exit(0);
    }

    pthread_createpthread_exit函数的无类型指针参数可以传递的参数的值不止一个,这个指针可以传递包含复杂信息的结构的地址,但,这个结构所使用的内存在调用者完成调用后必须仍然是有效的。如果是在栈中分配的空间,则其他线程在使用这个结构时内存内容可能已经改变了。

    例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    #include<pthread.h>
    #include"apue.h"

    struct foo{
    int a,b,c,d;
    };

    void printfoo(const char *s, const foo *fp)
    {
    printf("%s",s);
    printf(" structure at 0x%lx\n",(unsigned long)fp);
    printf(" foo.a = %d\n", fp->a);
    printf(" foo.b = %d\n", fp->b);
    printf(" foo.c = %d\n", fp->c);
    printf(" foo.d = %d\n", fp->d);
    }

    void *thr_fn1(void *arg)
    {
    foo fo = {1, 2, 3, 4};
    printfoo("thread 1:\n", &fo);
    pthread_exit((void*)&fo);
    }

    void *thr_fn2(void *arg)
    {
    printf("thread 2: ID is %lu\n", (unsigned long)(pthread_self()));
    pthread_exit((void*)0);
    }

    int main(void)
    {
    int err;
    pthread_t tid1, tid2;
    foo *fp;
    err = pthread_create(&tid1, NULL, thr_fn1, NULL);
    if(err!=0)
    {
    err_exit(err, "can't create thread");
    }

    err = pthread_join(tid1, (void **)&fp);

    if(err!=0)
    {
    err_exit(err, "can't join with thread 1");
    }

    sleep(1);

    printf("parent starting second thread\n");

    err = pthread_create(&tid2, NULL, thr_fn2, NULL);
    if(err!=0)
    {
    err_exit(err, "can't create thread");
    }
    sleep(2);
    printfoo("parent:\n",fp);
    exit(0);
    }

    执行结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    $ ./pthread_error.o 
    thread 1:
    structure at 0x7f48ad896ed0
    foo.a = 1
    foo.b = 2
    foo.c = 3
    foo.d = 4
    parent starting second thread
    thread 2: ID is 139950125840128
    parent:
    structure at 0x7f48ad896ed0
    foo.a = -1379403936
    foo.b = 32584
    foo.c = -1381808558
    foo.d = 32584

    线程可以调用pthread_cancel函数来请求取消同一进程的其他线程:

    1
    2
    3
    #include<pthread.h>
    int pthread_cancel(pthread_t tid);
    //成功返回0,否则返回错误编号

    默认情况下,pthread_cancel函数会使得由tid标识的线程行为表现为如同调用了参数为PTHREAD_CANCELEDpthread_exit函数,但线程可以选择忽略取消或者控制如何被取消。pthread_cancel函数并不等待线程终止,仅仅是提出请求。

    线程可以安排其退出时需要调用的函数,这与进程在退出时可以用atexit函数安排退出类似。这样的函数称为线程清理处理程序。一个线程可以建立多个清理处理程序。处理程序记录在栈中,即执行顺序与注册顺序相反:

    1
    2
    3
    #include<pthread.h>
    void pthread_cleanup_push(void (*rtn)(void *), void *arg);
    void pthread_cleanup_pop(int execute);

    当线程执行以下动作时,清理函数rtnpthread_cleanup_push函数调度的,调用时只有一个参数arg

    1. 调用pthread_exit时;
    2. 响应取消请求时;
    3. 用非零execute参数调用pthrea_cleanup_pop函数时。

    如果execute参数设置为0,清理函数不会被调用。不管发生哪种情况,pthread_cleanup_pop都将删除上次pthread_cleanup_push调用建立的清理处理程序。

    例程:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    #include"apue.h"
    #include<pthread.h>

    void cleanup(void *arg)
    {
    printf("cleanup: %s\n", (char*)arg);
    }

    void *thr_fn1(void *arg)
    {
    printf("thread 1 start\n");
    pthread_cleanup_push(cleanup, (void *)"thread 1 first handler");
    pthread_cleanup_push(cleanup, (void *)"thread 1 second handler");
    printf("thread 1 push complete\n");
    if((int*)arg)
    {
    return (void*)1;
    }
    pthread_cleanup_pop(0);
    pthread_cleanup_pop(0);
    return (void*)1;
    }

    void *thr_fn2(void *arg)
    {
    printf("thread 2 start\n");
    pthread_cleanup_push(cleanup, (void *)"thread 2 first handler");
    pthread_cleanup_push(cleanup, (void*)"thread 2 second handler");
    printf("thread 2 push complete\n");
    if((int*)arg)
    {
    pthread_exit((void*)2);
    }
    pthread_cleanup_pop(0);
    pthread_cleanup_pop(0);
    pthread_exit((void*)2);
    }

    int main(void)
    {
    int err;
    pthread_t tid1, tid2;
    void *tret;
    err = pthread_create(&tid1, NULL, thr_fn1, (void*)1);
    if(err!=0)
    {
    err_exit(err, "can't create thread 1");
    }
    err = pthread_create(&tid2, NULL, thr_fn2, (void*)1);
    if(err!=0)
    {
    err_exit(err, "can't create thread 2");
    }
    err = pthread_join(tid1, &tret);
    if(err!=0)
    {
    err_exit(err, "can't join thread 1");
    }
    printf("thread 1 exit code %ld\n", (long)tret);

    err = pthread_join(tid2, &tret);
    if(err!=0)
    {
    err_exit(err, "can't join thread 2");
    }
    printf("thread 2 exit code %ld\n", (long)tret);
    exit(0);
    }

执行结果:

1
2
3
4
5
6
7
8
9
10
11
$ ./pthread_clean.o 
thread 1 start
thread 1 push complete
thread 2 start
thread 2 push complete
cleanup: thread 1 second handler
cleanup: thread 1 first handler
cleanup: thread 2 second handler
cleanup: thread 2 first handler
thread 1 exit code 1
thread 2 exit code 2

与书中所述存在差异,书中说只有第二个新进程执行了线程清理处理程序,认为线程如果通过启动例程中返回而终止,就不会执行清理处理函数,但在当前Linux下,好像也会执行,似乎是新版的Linux进行了改变。

进程与线程存在很多相似之处,可以使用下表总结:

进程原语 线程原语 描述
fork pthread_create 创建新的控制流。
exit pthread_exit 从现有的控制流中退出。
waitpid pthread_jooin 从控制流中获取退出状态。
atexit pthread_cleanup_push 注册在退出控制流时调用的函数。
getpid pthread_self 获取控制流ID。
abort pthread_cancel 请求控制流的非正常退出。

在默认情况下,线程的终止状态会保存直到对线程调用pthread_join。如果线程已经被分离,线程的底层存储资源可以在线程终止时立即被收回。在线程被分离后,我们不能用pthread_join函数等待它的终止状态,此后会产生未定义的行为。

可以调用pthread_detach分离线程:

1
2
3
#include<pthread.h>
int pthread_detach(pthread_t tid);
//成功返回0,否则返回错误编号。

线程同步

当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。当一个线程可以修改的变量,其他线程也可以读取或者修改的时候,我们需要对这些线程进行同步,确保他们在访问变量的存储内容时不会访问到无效的值。

互斥量

可以使用pthread的互斥接口来保存数据,确保同一时间只有一个线程访问数据。互斥量(mutex)从本质上来书其实是一把锁,在访问共享资源前对互斥进行设置(加锁),在访问完成后释放(解锁)互斥量。当对互斥量加锁后,任何其他视图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。

互斥变量是用pthread_mutex_t数据类型表示的。在使用互斥变量之前,必须首先对它进行初始化,可以将其设置为常量PTHREAD_MUTEX_INITIALIZER(只适用于静态分配的互斥量),也可以调用pthread_mutex_init函数进行初始化。如果动态分配互斥量(例如使用malloc),在释放内存前需要调用pthread_mutex_destroy

1
2
3
4
#include<pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destory(pthread_mutex_t *mutex);
//成功返回0,否则返回错误编号

要用默认属性初始化互斥量,只需把attr设置为NULL。

对互斥量加锁,使用pthread_mutex_lock,如果互斥量已经上锁,则调用线程阻塞直到互斥量被解锁。对互斥量解锁需要调用pthread_mutex_unlock

1
2
3
4
5
#include<pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//成功返回0,错误返回错误编号

如果不希望线程阻塞,可以使用pthread_mutex_trylock尝试对互斥量加锁。如果调用pthread_mutex_trylock时互斥量处于未锁状态,那么pthread_mutex_trylock会锁住互斥量,不会出现阻塞直接返回0,否则pthread_mutex_trylock就会失败,不能锁住互斥量,返回EBUSY

例程:保护某个结构的互斥量,当一个以上线程需要访问动态分配的对象时,我们在对象中加入引用计数,确保在所以使用该对象的线程完成访问数据之前,该对象空间不会被释放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include<stdlib.h>
#include<pthread.h>

struct foo
{
int f_cout;
pthread_mutex_t f_lock;
int f_id;
};

struct foo * foo_alloc(int id)
{
foo *fp;
if((fp = (foo*)malloc(sizeof(foo)))!=NULL)
{
fp->f_cout = 1;
fp->f_id = id;
if(pthread_mutex_init(&fp->f_lock, NULL)!=0)
{
free(fp);
return NULL;
}
}
};

void foo_hold(foo *fp)
{
pthread_mutex_lock(&fp->f_lock);
fp->f_cout++;
pthread_mutex_unlock(&fp->f_lock);
}

void foo_rele(foo *fp)
{
pthread_mutex_lock(&fp->f_lock);
if(--fp->f_cout == 0)
{
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_destroy(&fp->f_lock);
free(fp);
}
else{
pthread_mutex_unlock(&fp->f_lock);
}
}

这里忽略了线程在调用foo_hold之前是如何找到对象的。同时,如果有另一个线程正在调用foo_hold时阻塞等待互斥锁,这时即使该对象引用计数达到0,foo_rele释放该对象依旧是不对的。

避免死锁

如果一个线程企图对一个互斥量加锁两次,那么它自身就会陷入死锁状态。当存在一个以上互斥量时,如果允许一个线程一直占用一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程也在试图锁住第一个互斥量。由于两个线程都在相互请求另一个线程用于的资源,所以两个线程都无法前进,于是产生死锁。

可能出现死锁只会发生在一个线程试图锁住另一个线程以相反的顺序锁住的互斥量。

更新上一节的例程,添加一个散列表用来实现线程获取结构,同时需要对散列表加锁,在同时需要两个互斥锁时,总是以相同的顺序加锁,这样可以避免死锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include<stdlib.h>
#include<pthread.h>

#define NHASH 29
#define HASH(id) (((unsigned long)id)%29)

struct foo
{
int f_cout;
pthread_mutex_t f_lock;
int f_id;
foo *f_next;
};

foo *fh[NHASH];
pthread_mutex_t hashlock = PTHREAD_MUTEX_INITIALIZER;

struct foo * foo_alloc(int id)
{
foo *fp;
int idx;
if((fp = (foo*)malloc(sizeof(foo)))!=NULL)
{
fp->f_cout = 1;
fp->f_id = id;
if(pthread_mutex_init(&fp->f_lock, NULL)!=0)
{
free(fp);
return NULL;
}
idx = HASH(id);
pthread_mutex_lock(&hashlock);
fp->f_next = fh[idx];
fh[idx] = fp;
pthread_mutex_lock(&fp->f_lock);
pthread_mutex_unlock(&hashlock);
pthread_mutex_unlock(&fp->f_lock);
}
};

void foo_hold(foo *fp)
{
pthread_mutex_lock(&fp->f_lock);
fp->f_cout++;
pthread_mutex_unlock(&fp->f_lock);
}

foo *foo_find(int id)
{
foo *fp;
pthread_mutex_lock(&hashlock);
for(fp=fh[HASH(id)]; fp!=NULL; fp = fp->f_next)
{
if(fp->f_id == id)
{
foo_hold(fp);
break;
}
}
pthread_mutex_unlock(&hashlock);
return fp;
}

void foo_rele(foo *fp)
{
foo *tfp;
int idx;
pthread_mutex_lock(&fp->f_lock);
if(fp->f_cout == 1)
{
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_lock(&hashlock);
pthread_mutex_lock(&fp->f_lock);
if(fp->f_cout != 1)
{
fp->f_cout--;
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_unlock(&hashlock);
return;
}
idx = HASH(fp->f_id);
tfp = fh[idx];
if(tfp == fp)
{
fh[idx] = fp->f_next;
}
else{
while(tfp->f_next != fp)
{
tfp = tfp->f_next;
}
tfp->f_next = fp->f_next;
}
pthread_mutex_unlock(&hashlock);
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_destroy(&fp->f_lock);
free(fp);
}
else{
fp->f_cout--;
pthread_mutex_unlock(&fp->f_lock);
}
}

这里主要交互函数为foo_findfoo_allocfoo_rele。这里这三个函数都是先锁住散列表再锁住指定元素。因此不会发生死锁。但我认为这里存在一个问题:1. 如果有一个或多个线程调用foo_find查找指定id的元素,当其不存在时,应该会调用foo_alloc进行初始化一个,而这时可能有多个线程调用foo_alloc函数创建同一个对象,此时可能造成生成多个重复元素。解决的办法我想到两个,第一个方式是在调用foo_find如果未找到指定id的元素,不释放散列表的锁,直接进行创建新元素。而后在释放散列表的锁,此时,只有第一个查询的进程会创建,其后的进程再使用foo_find时就存在该元素了,只会在其上引用计数上加1。第二种方式是,在函数foo_alloc获得散列表的锁之后,再次检查指定id的元素是否存在,如果存在就不创建了,只在其引用计数上加1。(其实这里问题不大,即使创建了多个,在之后查找的时候也会能找到的,但是这将对其他数据进行多余的拷贝,而且可能出现其他问题,个人拙见,还望更明白的人赐教)。

上述代码还可以进行简化,考虑每次操作都是先获取散列表锁,再获得元素锁。由于三个函数都是这样,因此其实我们可以只获取散列表锁即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include<stdlib.h>
#include<pthread.h>

#define NHASH 29
#define HASH(id) (((unsigned long)id)%29)

struct foo
{
int f_cout;
// pthread_mutex_t f_lock;
int f_id;
foo *f_next;
};

foo *fh[NHASH];
pthread_mutex_t hashlock = PTHREAD_MUTEX_INITIALIZER;

struct foo * foo_alloc(int id)
{
foo *fp;
int idx;
if((fp = (foo*)malloc(sizeof(foo)))!=NULL)
{
fp->f_cout = 1;
fp->f_id = id;
if(pthread_mutex_init(&fp->f_lock, NULL)!=0)
{
free(fp);
return NULL;
}
idx = HASH(id);
pthread_mutex_lock(&hashlock);
fp->f_next = fh[idx];
fh[idx] = fp;
// pthread_mutex_lock(&fp->f_lock);
pthread_mutex_unlock(&hashlock);
// pthread_mutex_unlock(&fp->f_lock);
}
};

void foo_hold(foo *fp)
{
pthread_mutex_lock(&hashlock);
fp->f_cout++;
pthread_mutex_unlock(&hashlock);
}

foo *foo_find(int id)
{
foo *fp;
pthread_mutex_lock(&hashlock);
for(fp=fh[HASH(id)]; fp!=NULL; fp = fp->f_next)
{
if(fp->f_id == id)
{
foo_hold(fp);
break;
}
}
pthread_mutex_unlock(&hashlock);
return fp;
}

void foo_rele(foo *fp)
{
foo *tfp;
int idx;
pthread_mutex_lock(&hashlock);
if(--fp->f_cout == 0)
{
// pthread_mutex_unlock(&fp->f_lock);
// pthread_mutex_lock(&hashlock);
// pthread_mutex_lock(&fp->f_lock);
// if(fp->f_cout != 1)
// {
// fp->f_cout--;
// pthread_mutex_unlock(&fp->f_lock);
// pthread_mutex_unlock(&hashlock);
// return;
// }
idx = HASH(fp->f_id);
tfp = fh[idx];
if(tfp == fp)
{
fh[idx] = fp->f_next;
}
else{
while(tfp->f_next != fp)
{
tfp = tfp->f_next;
}
tfp->f_next = fp->f_next;
}
pthread_mutex_unlock(&hashlock);
// pthread_mutex_unlock(&fp->f_lock);
// pthread_mutex_destroy(&fp->f_lock);
free(fp);
}
else{
fp->f_cout--;
pthread_mutex_unlock(&hashlock);
}
}

多线程软件设计涉及两者之间的折中。如果锁的粒度太粗,就会出现很多线程等待相同的锁,这可能不能改善并发性,如果锁的粒度太细,那么过多的锁开销会使系统性能受到影响,并且代码变得复杂。

函数pthread_mutex_timedlock

pthread_mutex_timedlockpthread_mutex_lock基本是等价的,区别在于,前者可以设定一个时间值,如果超时,就不会对互斥量进行加锁了,而是返回错误码ETIMEOUT

1
2
3
4
#include<pthread.h>
#include<time.h>
int pthread_mutex_timedlock(pthread_mutex_t *restarict mutex, const struct timespec *restrict tspr);
//成功返回0,否则返回错误编号

这里的时间值为绝对时间,即愿意等待到何时而并不是愿意等待多久。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include"apue.h"
#include<pthread.h>

int main(void)
{
pthread_mutex_t tid;
pthread_mutex_init(&tid,NULL);
timespec tmp;
char buf[64];

int err;
pthread_mutex_lock(&tid);
printf("tid has been locked\n");
clock_gettime(CLOCK_REALTIME,&tmp);
tm *time = localtime(&tmp.tv_sec);
strftime(buf, sizeof(buf), "%r", time);
printf("current time is : %s\n", buf);
tmp.tv_sec += 5;
printf("again try lock tid\n");
err = pthread_mutex_timedlock(&tid, &tmp);

clock_gettime(CLOCK_REALTIME,&tmp);
time = localtime(&tmp.tv_sec);
strftime(buf, sizeof(buf), "%r", time);
printf("the time is now : %s\n", buf);
if(err!=0)
{
printf("can't lock tid again: %s\n", strerror(err));
}
else{
printf("tid lock again\n");
}
exit(0);

}

这里尝试在同一个线程对同一个互斥量加两次锁,以此来验证超时。

读写锁

互斥锁只有两种状态,要么是锁住,要么是未锁,而且一次只能有一个线程可以对其加锁。读写锁有三个状态:读模式下加锁,写模式下加锁,不加锁状态。一次只能有一个线程可以占有写模式的读写锁,但可以有多个线程可以同时占有写模式的读写锁。

当读写锁是写状态加锁时,在锁状态被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图对其加锁的线程都可以得到访问权限,但是任何希望以写模式进行加锁的进程会阻塞,直到所有的线程释放它们的读锁为止。当读写锁处于读模式锁住状态,而这是有一个线程试图以写模式获取锁时,读写锁会阻塞随后的读模式锁请求,这样可以避免读模式锁长期占用,而写模式锁请求一直无法满足。

读写锁适合于对数据结构读的次数远大于写的情况。读写锁又叫共享互斥锁,当读模式锁住时,可以说是共享模式锁住的,以写模式锁住时,可以说是互斥模式锁住的。

读写锁在使用之前必须初始化,在释放底层内存之前必须销毁:

1
2
3
4
#include<pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destory(pthread_rwlock_t *rwlock);
//成功返回0,否则返回错误编号

attrNULL时,采用默认初始化。获取读写锁和释放读写锁采用下面的函数:

1
2
3
4
5
#include<pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pyhread_rwlock_unlock(pthread_rwlock_t *rwlock);
//成功返回0,否则返回错误编号

标准还定义了读写锁原语的条件版本:

1
2
3
4
#include<pthread.h>
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
///成功返回0,否则返回错误编号

可以获取锁时,函数返回0,否则返回错误EBUSY

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#include<pthread.h>
#include"apue.h"

//任务
struct job{
job *j_next;
job *j_prev;
pthread_t pid; //分配给哪个线程
};

//任务队列
struct queue{
job *q_head;
job *q_tail;
pthread_rwlock_t q_lock;
};

//初始化队列
int queue_init(queue *qp)
{
int err;
qp->q_head = NULL;
qp->q_tail = NULL;
if((err = pthread_rwlock_init(&qp->q_lock, NULL))!=0)
{
return err;
}
return 0;
}

//队列前添加任务
void job_inser(queue *qp, job *jp)
{
pthread_rwlock_wrlock(&qp->q_lock);
jp->j_next = qp->q_head;
if(qp->q_head != NULL)
{
qp->q_head->j_prev = jp;
}
else{
qp->q_tail = jp;
}
qp->q_head = jp;
jp->j_prev = NULL;
pthread_rwlock_unlock(&qp->q_lock);
}

//队尾添加任务
void job_append(queue *qp, job *jp)
{
pthread_rwlock_wrlock(&qp->q_lock);
jp->j_next = NULL;
jp->j_prev = qp->q_tail;
if(qp->q_tail != NULL)
{
qp->q_tail->j_next = jp;
}
else{
qp->q_head = jp;
}
qp->q_tail = jp;
pthread_rwlock_unlock(&qp->q_lock);
}

void job_move(queue *qp,job *jp)
{
pthread_rwlock_wrlock(&qp->q_lock);
if(jp == qp->q_head)
{
qp->q_head = jp->j_next;
if(jp == qp->q_tail)
{
qp->q_tail = NULL;
}
else{
jp->j_next->j_prev = jp->j_prev;
}
}
else{
if(jp == qp->q_tail)
{
qp->q_tail = jp->j_prev;
qp->q_tail->j_next = NULL;
}
else{
jp->j_next->j_prev = jp->j_prev;
jp->j_prev->j_next = jp->j_next;
}
}
pthread_rwlock_unlock(&qp->q_lock);
}

//寻找任务列表中第一个线程为id的任务
job *find_job(queue *qp, pthread_t id)
{
job *jp;
if(pthread_rwlock_rdlock(&qp->q_lock)!=0)
{
return NULL;
}
for(jp = qp->q_head; jp!=NULL; jp = jp->j_next)
{
if(pthread_equal(jp->pid, id) == 0)
{
break;
}
}
pthread_rwlock_unlock(&qp->q_lock);
return jp;
}

这里实现一个简单的任务队列,可以队列的任务都被分配给指定进程,对队列写时要获得读锁,读时获取读锁。

带有超时的读写锁

为了避免获取读写锁时一直处于堵塞状态,标准定义了带有超时的读写锁:

1
2
3
4
5
#include<pthread.h>
#include<time.h>
int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock, const struct timespec *restrict tsptr);
int pthread_rwlock_timedwrlock(pthread_rwlock_t *restrict rwlock, const struct timespec *restrict tsptr);
//成功返回0,否则返回错误编号

时间依旧是绝对值。

条件变量

条件变量是另一种同步机制。其为多个线程提供了一个会和的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定条件发生。

条件变量本身是由互斥量保护的。线程在改变条件状态之前必须首先锁住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁住之后才能计算条件。

pthread_cond_t类型为条件变量,初始化和反初始化方式如下,常量可以使用PTHREAD_COND_INITIALIZER直接赋值:

1
2
3
4
#include<pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
//成功返回0,否则返回错误编号

attrNULL时表示默认初始化。

我们使用pthread_cond_wait等待条件变为真,如果指定时间内不能满足,则返回错误码:

1
2
3
4
#include<pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const timespec *restrict tspr);
//成功返回0,否则返回错误编号

传递给pthread_cond_wait的互斥量对条件进行保护。调用者把锁住的互斥量传递给函数,函数随后自动把调用线程放到等待条件的线程列表上,对互斥量解锁。pthread_cond_wait返回时,互斥量将再次被锁住。这里互斥量与条件变量没有太大关系,只是提供一个保护,一般应该是一个在该条件满足后,后续执行的代码要求获取的一个互斥量,真正与条件绑定的还是条件变量。

如果超时条件还未出现,pthread_cond_timedwait将重新获得互斥量,然后返回错误ETIMEOUT。从pthread_cond_timedwaipthread_cond_wait调用成功返回,需要重新计算条件,因为另一个线程可能已经在运行并改变了条件。(具体参看下面的例子)

有两个函数可以通知线程条件已经满足:

1
2
3
4
#include<pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
//成功返回0,否则返回错误编号

pthread_cond_signal至少能唤醒一个等待该条件的线程,pthread_cond_broadcast则唤醒等待该条件的所以线程。调用二者时,我们说这是在给线程或者条件发信号,必须要在改变条件状态以后再给线程发信号。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include<pthread.h>

//消息类
struct msg{
struct msg *m_next;
};

//消息链表
msg *workq;

pthread_cond_t qready = PTHREAD_COND_INITIALIZER;

pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;

void process_mag(void)
{
msg *mp;
while (1)
{
pthread_mutex_lock(&qlock);
/*这里使用while,就是上文所述,当从pthread_cond_timedwai或
pthread_cond_wait调用成功返回,需要重新计算条件,
因为另一个线程可能已经在运行并改变了条件。
这里条件变量绑定的条件就是消息链表非空*/
while(workq == NULL)
{
pthread_cond_wait(&qready, &qlock);
}
mp = workq;
workq = mp->m_next;
pthread_mutex_unlock(&qlock);
/* process msg*/
}

}

//向消息链表的头部加一个消息,并向等待条件的进程进行广播
void equeue_msg(msg *mp)
{
pthread_mutex_lock(&qlock);
mp->m_next = workq;
workq = mp;
pthread_mutex_unlock(&qlock);
pthread_cond_signal(&qready);
}

自旋锁

好像实用性不大,书中说一般用不到,偷个懒。

屏障

屏障是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程到达某一点,然后从改点继续执行。pthread_join就是一种屏障,允许一个线程等待,直到另一个线程退出。

pthread_barrier_init是屏障类,下面的函数可以进行初始化和反初始化:

1
2
3
4
#include<pthread.h>
int pthread_barrier_init(pthread_barrier_t *restrict barrier, const pthread_barrierattr_t *restrict attr, unsigned int count);
int pthread_barrier_destory(pthread_barrier_t *barrier);
//成功返回0,否则返回错误编号

初始化屏障时,使用count参数指定,在允许所以线程运行之前,必须到达屏障的线程数目。

使用函数pthread_barrier_wait函数来表面调用线程已完成任务,等待其他线程赶来:

1
2
3
#include<pthread.h>
int pthread_barrier_wait(pthread_barrier_t *barrier);
//成功返回0或者PTHREAD_BARRIER_SERIAL_THREAD,否则返回错误编号。

调用pthread_barrier_wait的线程在屏障计数未满足条件时,会进入休眠状态。如果该线程是最后一个调用pthread_barrier_wait的线程,就满足了屏障计数,所以线程就会被唤醒。

对于任意一个线程,pthread_barrier_wait返回了PTHREAD_BARRIER_SERIAL_THREAD。剩下的进程看到的返回值是0,。这使得一个线程可以作为主线程,它可以工作在其他所有线程已完成的结果上。

一旦达到屏障计数,而且线程处于非阻塞状态,屏障就可以被重用,但除非在反初始化之后又重新进行了初始化,否则屏障计数不变。

例程:八个线程对一个数组进行堆排序,将数组拆成八份,最后利用归并的方法进行合并:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#include<pthread.h>
#include"apue.h"
#include<limits.h>
#include<sys/time.h>
#undef max
#undef min
#include<algorithm>
using namespace std;

#define NTHR 8 //线程数
#define NUMNUM 8000000L //数组大小
#define TNUM (NUMNUM/NTHR) //每个线程排序数量

long nums[NUMNUM];

long snums[NUMNUM];

#ifdef SOLARIS
#define heapsort qsort
#else
extern int heapsort(void *, size_t, size_t, int (*)(const void *, const void *));
#endif

pthread_barrier_t b;

bool complong(const long &arg1, const long &arg2)
{
if(arg1<arg2)
{
return 1;
}
return 0;
}

void *thr_fn(void *arg)
{
long idx = (long)arg;
sort(nums+idx, nums+idx+TNUM,complong);

pthread_barrier_wait(&b);
return ((void*)0);
}

void merge()
{
long idx[NTHR];
long i, minidx, sidx, num;
for(int i=0;i<NTHR;i++)
{
idx[i] = i*TNUM;
}
for(sidx = 0;sidx<NUMNUM; sidx++)
{
num = LONG_MAX;
for(i = 0; i<NTHR; i++)
{
if(idx[i]<(i+1)*TNUM && (nums[idx[i]]<num))
{
num = nums[idx[i]];
minidx = i;
}
}
snums[sidx] = nums[idx[minidx]];
idx[minidx]++;
}
}

int main()
{
unsigned long i;
timeval start, end;
long long startsec, endsec;
double elapsed;
int err;
pthread_t tid;

srandom(1);
for(i =0 ;i<NUMNUM; i++)
{
nums[i] = random();
}
gettimeofday(&start, NULL);
pthread_barrier_init(&b, NULL, NTHR+1);

for(i = 0;i<NTHR;i++)
{
err = pthread_create(&tid, NULL, thr_fn, (void*)(i*TNUM));
if(err!=0)
{
err_exit(err, "can't create thread");
}
}
pthread_barrier_wait(&b);
merge();
gettimeofday(&end, NULL);
startsec = start.tv_sec * 1000000 + start.tv_usec;
endsec = end.tv_sec * 1000000 + end.tv_usec;
elapsed = (double)(endsec-startsec)/1000000.0;
printf("sort1 took %.4f seconds\n", elapsed);

srandom(1);
for(i =0 ;i<NUMNUM; i++)
{
nums[i] = random();
}
gettimeofday(&start, NULL);
sort(nums, nums+NUMNUM, complong);
gettimeofday(&end, NULL);
startsec = start.tv_sec * 1000000 + start.tv_usec;
endsec = end.tv_sec * 1000000 + end.tv_usec;
elapsed = (double)(endsec-startsec)/1000000.0;
printf("sort2 took %.4f seconds\n", elapsed);
exit(0);
}

执行结果:

1
2
3
$ ./pthread_barrier.o 
sort1 took 0.7863 seconds
sort2 took 2.4713 seconds

十二章 线程控制

线程限制

线程相关限制有下面所述:,这些参数都可以使用sysconf函数获得

限制名称 描述 name参数
PTHREAD_DESTRUCTOR_ITERATIONS 线程退出时操作系统实现试图销毁线程特定数据的最大次数。 _SC_THREAD_DESTRUCTOR_ITERATIONS
PTHREAD_KEYS_MAX 进程可以创建的键的最大数目。 _SC_THREAD_KEYS_MAX
PTHREAD_STACT_MIN 一个线程栈可用的最小字节数。 _SC_THREAD_START_MIN
PTHREAD_THREADS_MAX 进程可以创建的最大线程数。 _SC_THREAD_THREADS_MAX

线程属性

pthread接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为,管理这些属性的函数都遵循相同的模式:

(1)每个对象与它自己类型的属性对象进行关联(线程与线程属性关联,互斥量与互斥量属性关联等等)。一个属性对象可以代表多种属性。属性对象对应程序来说是不透明的。需要提供相应的函数来管理属性。

(2)有一个初始化函数,把属性设置为默认值。

(3)还有一个销毁对象的函数,如果初始化函数分配了与属性相关的资源,销毁函数负责释放这些资源。

(4)每一个属性都有一个从属性对象中获取属性值的函数。

(5)每一个函数都有一个设置属性值的函数,在这种情况下,属性值作为参数按值传递。

pthread_create函数中,我们可以使用phread_attr_t对线程属性进行设置,下面两个函数负责默认初始化和反初始化pthread_attr_t变量:

1
2
3
4
#include<pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destory(pthread_attr_t *attr);
//成功返回0,否则返回错误编号

pthread_attr_destory会销毁属性对象的动态分配的空间(如果是的话),同时还会用无效的值初始化属性对象。

线程属性类型如下:

名称 描述
detachstate 线程分离状态属性。
guardsize 线程栈末尾的警戒缓冲区大小(字节)。
stackaddr 线程栈的最低地址。
stacksize 线程栈的最小长度(字节)。

分离线程在上一章已经介绍过了,如果对现有的某个线程的终止状态不感兴趣,可以使用pthread_detach函数让操作系统在线程退出时就收回它所占用的资源。

可以修改pthread_attr_tdetachstate属性决定线程分离状态。detachstate有两个合法值,PTHREAD_CREATE_DETACHED,以分离状态创建进程,或PTREAD_CREATE_JOINABLE,正常启动线程,应用程序可以获取线程的终止状态(这两个均是int类型指针)。下面两个函数分别用来获取和设置pthread_attr_t的相应属性:

1
2
3
4
#include<pthread.h>
int pthread_attr_getdetachstate(const pthread_attr_t *restrict attr, int *detachstate);
int pthread_attr_setdetachstate(pthread_attr_t *attr, int *detachstate);
//成功返回0,否则返回错误编号

使用下面两个函数对线程栈属性进行更改:

1
2
3
4
#include<pthread.h>
int pthread_attr_getstack(const pthread_attr_t *restrict attr, void **restrict stackaddr, size_t *restrict stacksize);
int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
//成功返回0,否则返回错误编号

对于进程来说,虚地址空间的大小是固定的。进程只有一个栈,其大小不是问题。但对于线程来说,相同大小的虚地址空间必须被所以线程栈共享。如果应用程序使用了许多线程,以至于这些线程栈的累计大小超过了可用的虚地址空间,就需要减少默认线程栈大小。另一方面,如果线程调用的函数分配了大量自动变量,或者调用的函数涉及许多很深的栈帧,那么需要的栈大小可能比默认的大。

如果线程栈的虚地址空间用完了,可以使用malloc或者mmap(十四章)来为可替代的栈分配空间,并用pthread_attr_setstack函数来改变新建线程的栈位置。由stackaddr参数指定的地址可以用作线程栈的内存范围的最低可寻址地址,改地址与处理器结构相应的边界对齐。这里要假设mallocmmap所用的虚地址范围与线程栈当前使用的虚地址范围不同。

stackaddr被定义为栈的最低内存地址,但并不一定是栈的开始位置。对于一个给定的处理器结构来说,如果栈是从高地址向低地址方向增长,那么stackaddr线程属性将是3栈的结尾位置。

应用程序也可以通过下面两个函数获取和设置stacksize

1
2
3
4
#include<pthread.h>
int pthread_attr_getstacksize(const pthread_attr_t *restrict attr, size_t *restrict stacksize);
int pathread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
//成功返回0,否则返回错误编号

希望改变默认的栈的大小,又不想自己处理线程栈的分配问题,使用pathread_attr_setstacksize函数十分有用。设置stacksize不能小于PTHREAD_STACK_MIN

线程属性guardsize控制线程栈末尾之后用以避免栈溢出的扩展内存大小,默认取决于系统实现,通常是系统页大小。可以把guardsize线程属性设置为0,不允许属性的这种行为发生:在这种情况下,不不提供警戒缓冲区。如果修改了线程属性stackaddr,系统就认为我们自己管理栈,进而使栈警戒缓冲机制失效,这等同于把guardsize设置为0。

下面的函数可以获取和设置guardsize属性:

1
2
3
4
#include<pthread.h>
int pthread_attr_getguardsize(const pthread_attr_t *restrict attr, size_t *restrict guardsize);
int ptrhead_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);
//成功返回0,否则返回错误编号

如果guardsize线程属性被修改,操作系统可能会把它取为页大小的整数倍。如果线程的栈指针溢出到警戒区,应用程序就可能通过信号接收到出错信息。

同步属性

互斥量属性

对于互斥量非默认属性,可以使用下面函数进行初始化和反初始化:

1
2
3
4
#include<pthread.h>
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destory(pthread_mutexattr_t *attr);
//成功返回0,否则返回错误编号

pthread_mutexattr_init将用默认的互斥量属性初始化pthread_mutexattr_t结构。值得注意的三个属性是:进程共享属性、健壮属性和类型属性。

在进程中,多个进程可以访问同一个同步对象。这是默认行为,在这种情况下,进程共享互斥量属性需设置为PTHREAD_PROCESS_PRIVATE

在下面的章节,我们将看到存在这样的机制:允许相互独立的多个进程把同一个内存数据块映射到它们各自独立的地址空间中。和多个线程访问共享数据一样,多个进程访问共享数据通常也需要同步。如果进程共享互斥量属性设置为PTHREAD_PROCESS_SHARED,从多个进程彼此之间共享的内存数据块中分配的互斥量就可以用于这些进程的同步。

可以使用下面的函数来获取和设置进程共享属性:

1
2
3
4
#include<pthread.h>
int pthread_mutexattr_getpshared(const pthread_mutexattr_t *restrict attr, int *restrict pshared);
int pthread_mutexattr_setpshared(const pthread_mutexattr_t *restrict attr, int pshared);
//成功返回0,否则返回错误编号

互斥量的健壮属性与在多个进程共享的互斥量有关。这意味着,当持有互斥量的进程终止时,需要解决互斥量状态恢复的问题。在这种情况下,互斥量处于锁定状态,恢复起来很困难。其他阻塞在这个锁的进程将会一直阻塞下去。

可以使用下面的函数获取和设置互斥量的健壮属性:

1
2
3
4
#include<pthread.h>
int pthread_mutexattr_getrobust(const pthread_mutexattr_t *restrict attr, int *restrict robust);
int pthread_mutexattr_setrobust(const pthread_mutexattr_t *restrict attr, int robust);
//成功返回0,否则返回错误编号

健壮属性取值有两种情况,默认值是PTHREAD_MUTEX_STALLED,这意味着持有互斥量的进程终止时不采取特别的动作。另一个取值是PTHREAD_MUTEX_ROBUST,这个值将导致线程调用pthread_mutex_lock获取锁,而该锁被另一个进程持有,但终止时并未对该锁进行解锁,此时线程会阻塞,从pthread_mutex_lock返回值为EOWNERDEAD而不是0。应用程序可以通过这个特殊值获知,若有可能,不管它们保护的互斥量状态如何,都需要进行恢复。

使用健壮性改变了使用pthread_mutex_lock的方式,因为必须要检查三个值:不需要恢复的成功,需要恢复的成功以及失败。

如果应用状态无法恢复,在线程对互斥量解锁以后,该互斥量将处于永久不可用状态,为了避免这样的问题,线程可以调用pthread_mutex_consistent函数,指明与该互斥量相关的状态在互斥量解锁之前是一致的。

1
2
3
#include<pthread.h>
int pthread_mutex_consistent(pthread_mutex_t *mutex);
//成功返回0,否则返回错误编号

如果线程没有先调用pthread_mutex_consistent就对互斥量解锁,那么其他试图获取该互斥量的阻塞线程将会得到错误码ENOTRECOERABLE。如果发生这种情况,互斥量将不在可用。线程通过提前调用pthread_mutex_consistent,就能让互斥量正常工作,这样就可以持续被使用。

类型互斥量属性控制着互斥量的锁定特性:

互斥量类型 特性 没有解锁时重新加锁 不占用时的解锁 在已解锁时解锁
PTHREAD_MUTEX_NORMAL 标准互斥量,不做错误检测和死锁检测 死锁 未定义 未定义
PTHREAD_MUTEX_ERRORCHECK 提供错误检查 返回错误 返回错误 返回错误
PTHREAD_MUTEX_RECURSIVE 运行同一个线程在互斥量解锁之前对该互斥量多次加锁。递归互斥量维护锁的计数。(加几次就一个解锁几次)。 允许 返回错误 返回错误
PTHREAD_MUTEX_DEFAULT 可以提供默认特性和行为。操作系统实现时把该类型自由映射到其他互斥量类型中的一种。 未定义 未定义 未定义

“不占用时加锁”是指,一个线程对另一个线程加锁的互斥量解锁,“已解锁时解锁”是指,一个线程对已经解锁的互斥量进行解锁。

使用下面的函数可以获取和设置互斥量类型属性:

1
2
3
4
#include<pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype(const pthread_mutexattr_t *restrict attr, int *restrict type);
//成功返回0,否则返回错误编号

例程:使用递归互斥量的情况,超时函数,允许安排另一个函数在未来某个时间运行,线程资源如果不是很昂贵,就可以为每一个挂起的超时函数创建一个线程,线程在未到时间时一直等待,时间到了再调用请求函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#include<pthread.h>
#include"apue.h"
#include<time.h>
#include<sys/time.h>

extern int makethread(void *(*)(void*), void *);

struct to_info{
void (*to_fn)(void *); //function
void *to_arg;
timespec to_wait;
};

#define SECTONSEC 1000000000 /* second to nanoseconds*/

#if !defined(CLOCK_REALTIME) || defined(BSD)
#define CLOCK_nanosleep(ID, FL, REQ, REM) nanosleep((REQ), (REM))
#endif

#ifndef CLOCK_REALTIME
#define CLOCK_REALTIME 0
#define USECTONSEC 1000 /* microseconds to nanosecond*/
void clock_gettime(int id, timespec *tsp)
{
timeval tv;
gettimeofday(&tv, NULL);
tsp->tv_sec = tv.tv_sec;
tsp->tv_nsec = tv.tv_usec*USECTONSEC
}
#endif

void *timeout_helper(void *arg)
{
to_info *tip = (struct to_info *)arg;
clock_nanosleep(CLOCK_REALTIME, 0, &tip->to_wait, NULL);
(*tip->to_fn)(tip->to_arg);
free(arg);
return 0;
}

void timeout(const timespec *when, void (*func)(void *), void *arg)
{
timespec now;
to_info *tip;
int err;
clock_gettime(CLOCK_REALTIME, &now);
if(when->tv_sec > now.tv_sec || (when->tv_sec == now.tv_sec && when->tv_nsec > now.tv_nsec))
{
tip = (to_info*)malloc(sizeof(to_info));
if(tip != NULL)
{
tip->to_fn = func;
tip->to_arg = arg;
tip->to_wait.tv_sec = when->tv_sec - now.tv_sec;
if(when->tv_nsec >= now.tv_nsec)
{
tip->to_wait.tv_nsec = when->tv_nsec - now.tv_nsec;
}
else{
tip->to_wait.tv_nsec = when->tv_nsec - now.tv_nsec + SECTONSEC;
tip->to_wait.tv_sec--;
}
err = makethread(timeout_helper, (void*)tip);
if(err == 0)
{
return;
}
else{
free(tip);
}
}
}
//如果when<=now,或者malloc失败,或创建线程失败,我们应该直接调用函数
(*func)(arg);
}

pthread_mutexattr_t attr;
pthread_mutex_t mutex;

void retry(void *arg)
{
pthread_mutex_lock(&mutex);
/* preform retry steps ...*/

pthread_mutex_unlock(&mutex);
}

int main(void)
{
int err, condition, arg;
timespec when;

if((err=pthread_mutexattr_init(&attr))!=0)
{
err_exit(err, "pthread_mutexattr_init error");
}
if((err=pthread_mutexattr_settype(%attr, PTHREAD_MUTEX_RECURSIVE))!=0)
{
err_exit(err, "can't set recursive type");
}
if((err = pthread_mutex_init(&mutex, &attr))!=0)
{
err_exit(err, "can't create recursive mutex");
}

/*continue process ...*/

pthread_mutex_lock(&mutex);
if(condition)
{
clock_gettime(CLOCK_REALTIME, &when);
when.tv_sec += 10;
timeout(&when, retry, (void*)((unsigned long)arg));
}

pthread_mutex_unlock(&mutex);
exit(0);

}

makethread函数以分类状态创建线程。由于传递给timeout函数的func函数参数将在未来运行,因此我们不希望一直空等待线程结束。

timeout的调用者需要占有互斥锁来检查条件,并且把retry函数安排为原子操作。retry函数试图对同一个互斥量进行加锁,如果互斥量不是递归的,会导致死锁。

读写锁属性

下面的函数用来对读写锁默认初始化和反初始化:

1
2
3
4
#include<pthread.h>
int pthread_rwlockattr_init(pthread_relockattr_t *attr);
int pthread_rwlockattr_destory(pthread_relockattr_t *attr);
//成功返回0,否则返回错误编号

读写锁唯一支持的属性是进程共享属性,其与互斥量的进程共享属性一致。下面的函数用来获取和设置读写锁的进程属性:

1
2
3
4
#include<pthread.h>
int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *restrict attr, int *restrict pshared);
int pthread_rwlockattr_setpshared(const pthread_rwlockattr_t *attr, int pshared);
//成功返回0,否则返回错误编号

条件变量属性

条件变量存在两个属性:进程共享属性和时钟属性。

下面的函数用来默认初始化和反初始化条件变量:

1
2
3
4
#include<pthread.h>
int pthread_condattr_init(pthread_condattr_t *attr);
int pthread_condattr_destory(pthread_condattr_t *attr);
//成功返回0,否则返回错误编号

条件变量的进程属性控制条件变量是被单进程的多线程使用还是多进程的线程使用。下面的函数获取和设置进程共享属性:

1
2
3
4
#include<pthread.h>
int pthread_condattr_getpshared(const pthread_condattr_t *restrict attr, int *restrict pshared);
int pthread_condattr_setpshared(const pthread_condattr_t *attr, int pshared);
//成功返回0,否则返回错误编号

时钟属性控制计算pthread_cond_timedwait函数的超时参数(tsptr)采用的哪个时钟。合法值为第六章时间和日期例程中第一个表中的值。下面的函数用来获取和设置时钟属性:

1
2
3
4
#include<pthread.h>
int pthread_condattr_getclock(const pthread_condattr_t *restrict attr, clockid_t *restrict clock_id);
int pthread_condattr_setclock(const pthread_condattr_t *attr, clockid_t clock_id);
//成功返回0,否则返回错误编号

屏障属性

下面的函数用来对屏障属性对象初始化和反初始化:

1
2
3
4
#include<pthread.h>
int pthread_barrierattr_init(pthread_barrierattr_t *attr);
int pthread_barrierattr_destory(pthread_barrierattr_t *attr);
//成功返回0,否则返回错误编号

屏障属性只有进程共享,与互斥量类似,下面的函数用来获取和设置进程共享属性:

1
2
3
4
#include<pthread.h>
int pthread_barrierattr_getpshared(const pthread_barrierattr_t *restrict attr, int *restrict pshared);
int pthread_barrierattr_setpshared(const pthread_barrierattr_t *attr, int *pshared);
//成功返回0,否则返回错误编号

重入

如果一个函数在相同的时间点可以被多个线程安全的调用,就称之为线程安全的。在标准的定义的函数除了下图列出来的函数,其他都保证是线程安全的。

线程不安全函数

对POSIX.1中的一些非线程安全函数,它会提供可替代的线程安全版本。下图列出了这些替代版本:

线程安全版本

如果一个函数对于多个线程来说是可重入的,就说这个函数是线程安全的。但并不能说明对信号处理程序来说该函数是可重入的。如果函数对异步信号处理程序的重入是安全的,那么就说函数是异步信号安全的。

除了上图,POSIX.1提供了以线程安全的方式管理FILE对象的方法。可以使用flockfileftrylockfile获取给定FILE对象关联的锁。这个锁是递归的。虽然这种锁的具体实现无规定,但要求所以操作FILE对象的标准I/O例程动作行为必须看起来就像他们内部调用了flockfilefunlockfile

1
2
3
4
5
6
#include<stdio.h>
int ftrylockfile(FILE *fp);
//成功返回0,如果不能获取锁,返回非0数组

void flockfile(FILE *fp);
void funlockfile(FILE *fp);

如果标准I/O例程都获取各自的锁,那么每次做一次一个字符的I/O时就会出现严重的性能下降。为了避免这种开销,出现了不加锁版本的基于字符的标准I/O例程:

1
2
3
4
5
6
7
8
#include<stdio.h>
int getchar_unlock(void);
int getc_unlocked(FILE *fp);
//成功,返回下一个字符,遇到文件结尾或出错,返回EOF

int putchar_unlocked(int c);
int putc_unlocked(int c, FILE *fp);
//成功返回c,出错返回EOF

除非被flockfilefunlockfile包围,否则尽量不要调用上面四个函数,因为它们会导致不可预期的结果。

第七节显示了一个getevn的可能实现,不过这个版本是不可重入的。如果两个线程同时调用这个函数,就会看到不一样的结果,因为所以getenv的线程返回的字符串都存储在同一静态缓冲区中:

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

#define MAXSTRINGSZ 4096

static char envbuf[MAXSTRINGSZ];

extern char **environ;

char *getenv(const char *name)
{
int i, len;
len = strlen(name);
for(i=0;environ[i]!=NULL;i++)
{
if((strncmp(name, environ[i],len)) && (environ[i][len] == '='))
{
strncpy(envbuf, &environ[i][len+1], MAXSTRINGSZ-1);
return envbuf;
}
}
return NULL;
}

下面给出了getenv的可重入版本。使用了pthread_once函数来确保不管多少线程同时竞争getenv_r,每个进程只调用thread_init函数一次,下一节会详细介绍pthread_once

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include<string.h>
#include<errno.h>
#include<pthread.h>
#include<stdlib.h>

extern char **environ;

pthread_mutex_t env_mutex;

static pthread_once_t init_done = PTHREAD_ONCE_INIT;

static void thread_init(void)
{
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&env_mutex, &attr);
pthread_mutexattr_destroy(&attr);
}

int getenv_t(const char *name, char *buf, int buflen)
{
int i, len, olen;
pthread_once(&init_done, thread_init);
len = strlen(name);
pthread_mutex_lock(&env_mutex);
for(i=0;environ[i] != NULL;i++)
{
if((strncmp(name, environ[i],len)) && (environ[i][len] == '='))
{
olen = strlen(&environ[i][len+1]);
if(olen >= buflen)
{
pthread_mutex_unlock(&env_mutex);
return ENOSPC;
}
else{
strcpy(buf, &environ[i][len+1]);
pthread_mutex_unlock(&env_mutex);
return 0;
}
}
}
pthread_mutex_unlock(&env_mutex);
return ENOSPC;
}

这里改变了原来getenv的接口,调用者必须提供自己的缓冲区,这样每个线程可以使用不同的缓冲区避免互相干扰。

线程特定数据

线程特定数据,也称为线程私有数据,是存储和查询某个特定线程相关数据的一种机制。对于这种数据,我们希望每个线程可以访问它自己的数据副本而不需要担心与其他线程同步访问问题。

线程需要特定数据的原因有两个:

  1. 线程ID不能保证是小而连续的整数,所以不能简单分配一个每组线程数组,用线程ID作为数组的索引。即使线程ID是小而连续的整数,我们可能希望有一些额外的保护,防止某个线程的数据与其他线程的数据相混乱。
  2. 线程特定数据提供了让基于进程的接口适应多线程环境的机制。一个典型的例子就是erron

在分配线程特定数据之前,需要创建与该数据关联的键。这个键将用于获取对线程特定数据的访问。使用pthread_key_create创建一个键:

1
2
3
#include<pthread.h>
int pthread_key_create(pthread_key_t *keyp, void (*destructor)(void*));
//成功返回0,否则返回错误编号

创建的键存储在keyp指向的内存单元中,这个键可以被进程中的所有线程使用,但每个线程把这个键与不同的线程特定数据地址关联。创建新键时,每个线程的数据地址设为空地址。

除了创建键以为,pthread_key_create可以关联一个析构函数。当线程退出时,如果数据地址已经被置为非空值,那么析构函数将会被调用。当线程调用了pthread_exit或者线程执行返回,正常退出时,析构函数就会被调用。线程取消时,只有在最后清理处理程序返回之后,析构函数才会被调用。如果线程调用了exit_Exitabort,或出现其他非正常的退出时,就不会调用析构函数。

线程通常使用malloc为线程特定数据分配内存。

对于所以的线程,我们通常可以调用pthread_key_delete来取消键与特定数据值之间的关联关系:

1
2
3
#include<pthread.h>
int pthread_key_delete(pthread_key_t key);
//成功返回0,否则返回错误编号

调用pthread_key_delete并不会激活与键关联的析构函数。要释放响应空间应该在应用程序中采取额外步骤。

对于同一个线程特定数据,pthread_key_create应该在一个进程中只执行一次,如果将pthread_key_create放在每个线程内执行,会导致不同线程看到的是不同的键值。解决这种竞争的办法是使用pthread_once

1
2
3
4
#include<pthread.h>
pthread_once_t initflag = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *initflag, void (*initfn)(void));
//成功返回0,否则返回错误编号

initflag必须是非本地变量(如全局变量或静态变量),而且必须初始化为PTHREAD_ONCE_INIT

如果每个线程都调用pthread_once,系统就能保证初始化例程initfn只被调用一次,即系统首次调用pthread_once时。

键一旦被创建后,就可以通过调用pthread_setspecific函数把键和线程特定数据关联起来。可以通过pthread_getspecific函数获取线程特定数据的地址:

1
2
3
4
5
6
#include<pthread.h>
void *pthread_getspecific(pthread_key_t key);
//返回线程特定数据,如果没有值与该键相关联,返回NULL

int pthread_setspecific(pthread_key_t key, const void *value);
//返回值,成功返回0,否则返回错误编号

例程:getenv函数的另一个版本,之前我们改变了函数接口,这里不该函数接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include<pthread.h>
#include<limits.h>
#include<stdlib.h>
#include<string.h>

#define MAXSTRINGSZ 4096

static pthread_key_t key;
static pthread_once_t init_done = PTHREAD_ONCE_INIT;
pthread_mutex_t env_mutex = PTHREAD_MUTEX_INITIALIZER;

extern char **environ;

static void thread_init(void)
{
pthread_key_create(&key, free);
}

char *getenv(const char *name)
{
int i, len;
char *envbuf;
pthread_once(&init_done, thread_init);
pthread_mutex_lock(&env_mutex);
envbuf = (char*)pthread_getspecific(key);
if(envbuf == NULL)
{
envbuf = (char*)malloc(MAXSTRINGSZ);
if(envbuf == NULL)
{
pthread_mutex_unlock(&env_mutex);
return NULL;
}
}
len = strlen(name);
for(i=0;environ[i] != NULL;i++)
{
if((strncmp(name, environ[i],len)) && (environ[i][len] == '='))
{
strncpy(envbuf, &environ[i][len+1], MAXSTRINGSZ-1);
pthread_mutex_unlock(&env_mutex);
return envbuf;
}
}
pthread_mutex_unlock(&env_mutex);
return NULL;
}

取消选项

有两个线程属性并没有包含在pthread_attr_t结构中,它们是可取消状态和可取消类型。这两个属性影响着线程在响应pthread_cansel函数调用时所呈现的行为。

可取消状态属性可以是PTHREAD_CANCEL_ENABLE,也可以是PTHREAD_CANCEL_DISABLE。线程可以通过下面的函数修改可取消状态:

1
2
3
#include<pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
//成功返回0,否则返回错误编号

pthread_cancel调用并不等待进程终止。在默认情况下,线程在取消请求发出后还是继续运行,直到运行到某个取消点。取消点是线程检查它是否被取消的一个位置,如果取消了,按请求行事。下列函数为执行完成后会出现取消点的函数:

取消点

线程默认的可取消状态为PTHREAD_CANCEL_ENABLE。当状态为PTHREAD_CANCEL_DISABLE时,对pthread_cancel调用并不会杀死进程。相反,取消请求对于这个线程来说还处于挂起状态,当取消状态再次变为PTHREAD_CANCEL_ENABLE时,线程将在下一个取消点上对所以挂起的取消请求进行处理。

可以调用pthread_testcancel函数在程序中添加自己的取消点:

1
2
#include<pthread.h>
void pthread_testcancel(void);

调用pthread_testcancel时,如果有某个取消请求正处于挂起状态,而且取消并没有置为无效,那么线程会立即取消。

我们所描述的默认的取消类型也称为推迟取消。调用pthread_cancel以后,在线程达到取消点以前,并不会真正的取消。可以通过调用下面的函数来修改取消类型:

1
2
3
#include<pthread.h>
int pthread_setcanceltype(int type, int *oldtype);
//成功返回0,否则返回错误编号

type可以是PTHREAD_CANCEL_DEFERRED(延迟取消)或PTHREAD_CANCEL_ASYNCHRONOUS(异步取消)。

异步取消可以在任意时间取消线程。

线程和信号

每个线程都有自己的信号屏蔽字,但是信号的处理是进程中所以进程共享的。进程中的信号是递送到单个进程的。如果一个信号与硬件故障相关,那么该信号一般会被发送到引起该事件的线程中去,而其他信号则被发送到任意一个线程。

第十章讨论了进程使用sigpromask函数来阻止信号发送。然而sigpromask的行为并未在多线程中定义,线程必须使用pthread_sigmask

1
2
3
#include<signal.h>
int pthread_sigmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
//成功返回0,否则返回错误编号

pthread_sigmask除了返回值,其它与sigpromask基本相同。

线程可以调用sigwait等待一个或多个信号的出现:

1
2
3
#include<signal.h>
int sigwait(const sigset_t *restrict set, int *restrict signop);
//成功返回0,否则返回错误编号

set参数指定了线程等待的信号集。返回时,signop指向的整数将包含发生的信号。

如果信号集中某个信号在sigwait调用的时候处于挂起状态,那么sigwait将无阻塞的返回。在返回之前,sigwait将从进程中移除那些处于挂起等待的信号。

为了避免错误发生,线程在调用sigwait之前,必须阻塞那些正在等待的信号。sigwait会原子地取消信号集的阻塞状态,直到有新的信号被递送。在返回之前,sigwait将恢复线程的信号屏蔽字。如果信号在sigwait被调用的时候没有被阻塞,那么在线程完成对sigwait的调用之前会出现一个时间窗,在这个时间窗中,信号就可以被发送给线程。

使用sigwait的好处是可以简化信号处理,允许把异步产生的信号用同步的方式处理。为了防止信号中断线程,可以把信号加到每一个线程的信号屏蔽字中。然后安排专用线程处理信号。这些专用线程可以进行函数调用,不需要担心在信号处理程序中调用哪些函数是安全的,因为这些函数调用来自正常的线程上下文,而非会中断线程正常执行的传统信号处理程序。

如果多个线程在sigwait的调用中因等待同一信号而阻塞,那么在信号递送的时候,就只有一个线程可以从sigwait中返回。具体由操作系统来决定如何递送信号。要把信号发送给线程,可以使用下面的函数:

1
2
3
#include<signal.h>
int pthread_kill(pthread_t thread, int signo);
//成功返回0,否则返回错误编号

闹钟定时器是进程资源,并且所以的线程共享相同的闹钟,所以进程的多个线程不可能互不干扰的使用闹钟定时器。

例程:实现第十章函数sigsuspend的捕捉中断信号和退出信号,但只有当是退出信号时时才唤醒进程,使用单独的线程处理信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include"apue.h"
#include<pthread.h>
int quitflag;
sigset_t mask;

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t waitloc = PTHREAD_COND_INITIALIZER;

void *thr_fn(void*arg)
{
int err, signo;

while(1)
{
err = sigwait(&mask, &signo);
if(err != 0)
{
err_exit(err, "sigwait failed");
}
switch(signo)
{
case SIGINT:
printf("\niterrupt\n");
break;

case SIGQUIT:
pthread_mutex_lock(&lock);
quitflag = 1;
pthread_mutex_unlock(&lock);
pthread_cond_signal(&waitloc);
return 0;

default:
printf("unexcepted signal %d\n", signo);
exit(1);

}
}
}

int main(void)
{
int err;
sigset_t oldmask;
pthread_t tid;

sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGQUIT);

//主线程屏蔽中断信号和退出信号,新键线程会继承该信号屏蔽。
if((err = pthread_sigmask(SIG_BLOCK, &mask, &oldmask))!=0)
{
err_exit(err, "sig_block errpr");
}

err = pthread_create(&tid, NULL, thr_fn, 0);
if(err!=0)
{
err_exit(err, "can't create thread");
}

//这里的lock只是起保护作用。
pthread_mutex_lock(&lock);
while(quitflag == 0)
{
pthread_cond_wait(&waitloc, &lock);
}
pthread_mutex_unlock(&lock);

quitflag = 0;

//恢复信号屏蔽字
if(sigprocmask(SIG_SETMASK, &oldmask, NULL)<0)
{
err_exit(err, "sig_setmask error");
}
exit(0);
}

线程和fork

当线程调用fork时,为子进程创建整个进程地址空间的副本。在第八章讲过写时复制策略,子进程与父进程是完全不同的进程,只要二者都没有对内存做出修改,父进程和子进程共享内存页的副本。

子进程通过继承整个地址空间的副本,还从父进程那里继承了每个互斥量、读写锁和条件变量的状态。如果父进程包含一个以上线程,子进程在fork之后如果不是立即调用exec的话,需要立即清理锁状态。

子进程内部,只存在一个线程,即父进程中调用frok的线程的副本构成的。如果父进程中的线程占用锁,子进程将同样占用这些锁。但子进程并不包含占有锁的线程的副本,所以子进程没有办法知道它占有的哪些锁、需要释放哪些锁。这样就导致子进程无法再使用父进程中的锁了,但他们占用的资源却不会被释放,这是极大的浪费。

如果子进程从fork返回后马上调用其中一个exec函数,就可以避免这样的问题。此时,旧的地址空间将被丢弃,所以锁的状态无关紧要。这里考虑的问题主要就是子进程的问题,对于父进程来说是无所谓的,父进程设计的合理时,自己会解锁的,而对于子进程来说,其并没有父进程前面的处理,因此对于子进程来说,锁是一个完全未知的状态,要想子进程能够正常使用父进程的锁,就应该让生成的子进程获取所以的锁的锁,这样,锁状态对于子进程就不是未知的了。

要清除锁状态,可以通过调用pthread_atfork函数建立fork处理程序:

1
2
3
#include<pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
//成功返回0,否则返回错误编号

pthread_atfork可以安装三个帮助清理锁的函数。parpare处理函数程序由父进程在fork创建子进程之前调用。该函数是获取父进程定义的所以锁。parent函数处理程序在fork创建子进程之后、在返回之前在父进程的上下文中调用的。这个处理程序用来释放prepare获取的锁。child处理程序在fork返回之前在子进程上下文中调用。与parent函数一样用来处理prepare获得的锁。执行过程为:

  1. 父进程获取所有的锁。
  2. 子进程获取所有的锁。
  3. 父进程释放它的锁。
  4. 子进程释放它的锁。

可以调用pthread_atfork参数从而设置多套fork处理函数。当某个函数床单为NULL时,表示不需要处理该部分。使用多个fork处理程序时,处理程序的调用顺序并不相同。parentchild处理程序是以他们注册时的顺序进行调用的,而prepare处理程序函数的调用顺序与注册的顺序相反。这样可以允许多个模块注册它们自己的fork处理程序,而且可以可保持锁的层次。

假设模块A调用模块B中的函数,而且每个模块有自己的一套锁。如果锁的层次是A在B之前,模块B必须在模块A之前设置它的fork处理程序。当父进程调用fork时:

  1. 调用子模块A的prepare的函数。
  2. 调用模块B的prepare函数。
  3. 创建子进程。
  4. 调用模块B的child函数。
  5. 创建模块A的child函数。
  6. fork函数返回到子进程。
  7. 调用模块B的parent函数。
  8. 调用模块A的parent函数。

虽然pthread_atfork机制的意图是是fork之后的锁状态保存一致,但它还是存在一些问题:

  1. 没有很好的办法对复杂同步对象(条件变量和屏障)进行状态的重新初始化。
  2. 某些错误检查的互斥量实现在child处理程序试图对被加锁的互斥量进行解锁时会发生错误。
  3. 递归互斥量不能在child程序中被清理,由于没有办法知道被加锁次数。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
#include "apue.h"
#include <pthread.h>

pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;

void prepare()
{
int err;
printf("prepare fn lock lock2\n");
if ((err = pthread_mutex_lock(&lock2)) != 0)
{
err_exit(err, "prepare can't lock lock1");
}
}

void parent()
{
int err;
printf("parent fn unlock lock2\n");
if ((err = pthread_mutex_unlock(&lock2)) != 0)
{
err_exit(err, "parent can't unlock lock1");
}
}

void child()
{
int err;
printf("child fn unlock lock2\n");
if ((err = pthread_mutex_unlock(&lock2)) != 0)
{
err_exit(err, "child can't unlock lock2");
}
printf("child fn unlock lock1\n");
if ((err = pthread_mutex_unlock(&lock1)) != 0)
{
err_exit(err, "child can't unlock lock1");
}
}

void *child_thr_fn(void *arg)
{
int err;
printf("child 2 lock lock1\n");
if ((err = pthread_mutex_lock(&lock1)) != 0)
{
err_exit(err, "child 2 can't lock lock1");
}
printf("child 2 lock lock2\n");
if ((err = pthread_mutex_lock(&lock2)) != 0)
{
err_exit(err, "child 2 can't lock lock2");
}
printf("child process here\n");

printf("child 2 lock unlock2\n");
if ((err = pthread_mutex_unlock(&lock2)) != 0)
{
err_exit(err, "child 2 can't unlock lock2");
}
printf("child 2 lock unlock1\n");
if ((err = pthread_mutex_unlock(&lock1)) != 0)
{
err_exit(err, "child 2 can't unlock lock1");
}
return (void *)0;
}

void *parent_thr_fn(void *arg)
{
int err;
printf("parent lock lock1\n");
if ((err = pthread_mutex_lock(&lock1)) != 0)
{
err_exit(err, "prepare can't lock lock1");
}
if ((err = pthread_atfork(prepare, parent, child)) != 0)
{
err_exit(err, "atfork error");
}
pid_t pid = fork();
// child
if (pid == 0)
{
pthread_t tid;
int err2 = pthread_create(&tid, NULL, child_thr_fn, NULL);
if (err2 != 0)
{
err_exit(err2, "child can't create pthread");
}
sleep(1);//等待新线程结束
int err3;
printf("child 2 lock lock1\n");
if ((err3 = pthread_mutex_lock(&lock1)) != 0)
{
err_exit(err, "child 1 can't lock lock1");
}
printf("child 1 lock lock2\n");
if ((err3 = pthread_mutex_lock(&lock2)) != 0)
{
err_exit(err, "child 1 can't lock lock2");
}
printf("child 1 process here\n");

printf("child 1 lock unlock2\n");
if ((err3 = pthread_mutex_unlock(&lock2)) != 0)
{
err_exit(err, "child 1 can't unlock lock2");
}
printf("child 1 lock unlock1\n");
if ((err3 = pthread_mutex_unlock(&lock1)) != 0)
{
err_exit(err, "child 1 can't unlock lock1");
}
printf("child finish\n");
}
else{
printf("parent unlock lock1\n");
if((err=pthread_mutex_unlock(&lock1))!=0)
{
err_exit(err, "parent can't unlock lock1");
}
printf("parent finish\n");
}
return (void*)0;
}

int main(void)
{
int err;
pthread_t tid;
printf("parent create pthread\n");
if((err=pthread_create(&tid, NULL, parent_thr_fn, NULL))!=0)
{
printf("parent can't create pthread\n");
}
sleep(10); //等待创建的线程完成
return 0;
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ ./pthread_atfork.o 
parent create pthread
parent lock lock1
prepare fn lock lock2
parent fn unlock lock2
parent unlock lock1
parent finish
child fn unlock lock2
child fn unlock lock1
child 2 lock lock1
child 2 lock lock2
child process here
child 2 lock unlock2
child 2 lock unlock1
child 2 lock lock1
child 1 lock lock2
child 1 process here
child 1 lock unlock2
child 1 lock unlock1
child finish

解释:程序首先创建一个在线程,在新创建的线程中获取锁lock1。注册fork处理程序。prepare获取锁lock2parent释放锁lock2child释放锁lock1lock2。而后创建新进程,原来的进程释放lock1结束。新进程中,由于child函数释放了两个锁,所以两个锁都是未锁定状态。先创建一个子线程,在子线程中获取两把锁,处理之后的程序,再释放两把锁,在原来的线程中先等待创建的子线程完成,而后获取两把锁,执行处理程序,而后释放两把锁。

线程和I/O

函数preadpwrite在多线程中是十分有用的,由于同一进程共享文件描述符,如果偏移量发生变化与读取之间又别的线程更改了偏移量,读取或写就会出错,preadpwrite将更改偏移量和读写组成了原子操作,这样保证读写的准确性。

第十三章 守护进程

守护进程是生存期长的一种进程。他们常常在系统引导装入时启动,仅在系统关闭时才终止。它们没有控制终端,因此都是在后台运行的,往往用来处理日常事物活动。

守护进程特性

ps命令打印系统中各个进程的状态。ps -axj:选项-a显示由其他用户拥有的进程的状态,-x显示没有控制终端的进程的状态,-j显示与作业有关的信息:会话ID、进程组ID、控制终端以及终端进程组ID。其输出是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ ps -axj
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
0 1 1 1 ? -1 Ss 0 0:02 /sbin/init splash
0 2 0 0 ? -1 S 0 0:00 [kthreadd]
2 7 0 0 ? -1 S 0 0:00 [ksoftirqd/0]
2 10 0 0 ? -1 S 0 0:00 [migration/0]
2 11 0 0 ? -1 S 0 0:00 [watchdog/0]
2 87 0 0 ? -1 I< 0 0:00 [writeback]
2 300 0 0 ? -1 I< 0 0:00 [ext4-rsv-conver]
1 1258 1258 1258 ? -1 Ss 0 0:00 /sbin/wpa_supplicant -u
1 1260 1260 1260 ? -1 Ssl 102 0:00 /usr/sbin/rsyslogd -n
1 1270 1270 1270 ? -1 Ssl 0 0:00 /usr/bin/python3 /usr/b
1 1273 1273 1273 ? -1 Ss 0 0:00 /lib/systemd/systemd-lo
1 1277 1277 1277 ? -1 Ssl 0 0:00 /usr/lib/accountsservic
1 1284 1284 1284 ? -1 Ssl 0 0:00 /usr/sbin/ModemManager
1 1286 1286 1286 ? -1 Ssl 0 0:00 /usr/lib/udisks2/udisks
1 1290 1290 1290 ? -1 Ssl 0 0:00 /usr/sbin/thermald --no
1 1293 1293 1293 ? -1 Ssl 0 0:00 /usr/sbin/irqbalance --
1 1295 1295 1295 ? -1 Ss 116 0:00 avahi-daemon: running [
1 1296 1296 1296 ? -1 Ssl 0 0:00 /usr/sbin/NetworkManage
1 1297 1297 1297 ? -1 Ssl 0 0:01 /usr/lib/snapd/snapd
1 1298 1298 1298 ? -1 Ss 0 0:00 /usr/sbin/cron -f
1 1503 1503 1503 ? -1 Ssl 0 0:00 /usr/sbin/gdm3
1 1515 1514 1514 ? -1 S 108 0:00 /usr/sbin/dnsmasq -x /r
1 1532 1531 1531 ? -1 Sl 125 0:00 /usr/sbin/mysqld --daem
1503 1533 1503 1503 ? -1 Sl 0 0:00 gdm-session-worker [pam
2 4652 0 0 ? -1 I 0 0:00 [kworker/3:1]
2 4673 0 0 ? -1 I 0 0:00 [kworker/u24:0]
2 4674 0 0 ? -1 I 0 0:00 [kworker/10:0]
2376 4707 4707 4707 ? -1 Rsl 1000 0:00 /usr/lib/gnome-terminal
4707 4718 4718 4718 pts/0 4727 Ss 1000 0:00 bash
4718 4727 4727 4718 pts/0 4727 R+ 1000 0:00 ps -axj

系统进程依赖于操作系统的实现。父进程为0的进程通常是内核进程,它们作为系统引导装入过程的一部分而启动。内核进程是特殊的,通常存在于系统的整个生命周期中。以超级用户特权运行,无控制终端,无命令行。

对于需要在进程上下文执行工作但却不被用户层进程上下文调用的每一个内核组件,通常有自己的内核守护进程。例如:

  1. kswapd守护进程也被称为内存换页守护进程。支持虚拟内存子系统在经过一段时间后将脏页面慢慢写回磁盘来回收这些页面。
  2. flush守护进程用于内存达到设置的最小阈值时将脏页面冲洗至磁盘。
  3. sync_supers守护进程定期将文件系统元数据冲洗至磁盘。
  4. jbd守护进程帮助实现exit4文件系统中的日志功能。

大多数守护进程都以超级用户特权运行。所以的守护进程都没有控制终端,其终端名设置为问号。大多数用户层守护进程都是进程组的组长进程以及会话的首地址,而且是这些进程组和会话的唯一进程(rsyslogd除外)。用户层守护进程的父进程是init进程。

编程规则

下面为守护进程的编译一般规则:

  1. 首先使用umask将文件模式创建屏蔽字设置为一个已知值(通常是0)。由继承得来的文件模式创建屏蔽字可能会被设置为拒绝某种权限。如果守护进程要创建文件,那么它可能要设置特定的权限。另一方面,如果守护进程调用的库函数创建了文件,那么将文件模式创建屏蔽字设置为一个限制性更强的值(如007)可能会更明智,因为库函数可能不允许调用者通过一个显示的函数来设置权限。
  2. 调用fork,然后使父进程exit。这样做实现了下面几点。第一:如果守护进程是作为一条简单的shell命令启动的,那么父进程终止会让shell认为这条命令已近执行完毕。第二:虽然子进程继承了父进程的进程组ID,但获得了一个新的进程ID,这将保证了子进程不是一个进程组的组长进程,这是下面将用进行的setsid调用的先决条件。(具体看第九章会话节)。在基于system v的系统中,建议再次调用fork,终止父进程,继续使用子进程中的守护进程。这就保证了该守护进程不是会话首进程,可以防止其取得控制终端。
  3. 调用setsid创建一个会话。使调用进程:(a)成为新会话的首进程,(b)成为一个新进场的进程组组长进程,(c)没有控制终端。
  4. 将当前工作目录改为根目录。从父进程继承过来的当前目录可能是在一个挂载的文件系统中。因为守护进程通常在系统再引导之前一直存在,所以守护进程的当前工作目录在一个文件系统中,那么该文件系统就不能被正常挂载。
  5. 关闭进程不在需要的文件描述符。
  6. 某些守护进程打开/dev/null使其具有文件描述符0、1和2,这样,任何一个试图读标准输入、写标准输出或者标准错误的库例程都不会产生任何效果。因为守护进程不与终端设备关联,所以其输出无处显示,也无处从交互式用户那里接收输入。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include"apue.h"
#include"syslog.h"
#include<fcntl.h>
#include<sys/resource.h>

void daemonize(const char *cmd)
{
int i, fd0, fd1, fd2;
pid_t pid;
rlimit rl;
struct sigaction sa;

// clear file creation mask
umask(0);

//get maximun number of file description
if(getrlimit(RLIMIT_NOFILE, &rl)<0)
{
err_quit("%s, can't get file limit", cmd);
}

// become a session leader to lose controlling TTY
if((pid = fork())<0)
{
err_quit("%s, can't fork", cmd);
}
else if(pid != 0)
{
exit(0);
}
setsid();

// ensure future opens won't allocate controlling TTYs
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if(sigaction(SIGHUP, &sa, NULL)<0)
{
err_quit("%s, can't ignore sighup", cmd);
}
if((pid = fork())<0)
{
err_quit("%s, can't fork", cmd);
}
else if(pid!=0)
{
exit(0);
}

// change the current working directory to the root
if(chdir("/")<0)
{
err_quit("%s, can't change directory to /", cmd);
}

//close all open file descriptors
if(rl.rlim_max == RLIM_INFINITY)
{
rl.rlim_max = 1024;
}
for(i=0;i<rl.rlim_max;i++)
{
close(i);
}

// attach file descriptors 0,1,2 to /dev/null
fd0 =open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);

// initialize the log file
openlog(cmd, LOG_CONS, LOG_DAEMON);
if(fd0 !=0 || fd1 != 1 || fd2 != 2)
{
syslog(LOG_ERR, "unexpection file descriptors %d,%d,%d", fd0, fd1, fd2);
exit(1);
}
}

int main()
{
daemonize("daemonzie");
}

出错记录

守护进程没有控制终端,不能简单的写到标准错误上。对于出错记录BSD的syslog设施被广泛应用:

写错误

有以下三种日志生成的方式:

  1. 内核例程调用log函数。任何一个进程都可以通过打开(open)并读取(read/dev/klog设备来读取这些消息。
  2. 大多数用户进程(守护进程)调用syslog函数来产生日志消息。下面将进行详细的解释。这使得消息被发送至UNIX域数据报套接字/dev/log
  3. 无论一个用户进程是在此主机上,还是通过TCP/IP网络连接到此主机的其他主机上。都可以将日志消息发送到UDP端口514。注意:syslog函数不产生这些UDP数据报,它们要求产生此日志消息的进程进行显示的网络编程。

通常syslogd守护进程读取所以三种格式的日志消息。此守护进程在启动时读取一个配置文件,其名通常是/etc/syslog.conf,该文件决定了不同种类的消息该发送至何处。例如:紧急消息可发送至系统管理员(若已登录),并在控制台上打印,而警告信息则可记录到一个文件中。接口函数为:

1
2
3
4
5
6
#include<syslog.h>
void openlog(const char *ident, int open, int facility);
void syslog(int priority, const char *format, ...);
void closelog(void);
int setlogmask(int maskpir);
//返回值:前日志记录优先级屏蔽字值

调用openlog是可选的。如果不调用openlog,则在第一次调用syslog时,自动调用openlog。调用closelog也是可选的,它只是关闭曾用于与syslogd守护进程进行通信的描述符。

调用opnlog使我们可以指定一个ident,以后此ident将被加至每则日志消息中。ident一般是程序名字。option参数是指定各种选项的位屏蔽。可选下面值:

option 说明
LOG_CONS 若日志消息不能通过UNIX域数据报送至syslogd,则将消息写至控制台。
LOG_NDELAY 立即打开至syslogd守护进程的UNIX域数据报套接字,不要等到第一条消息已经被记录时才打开。通常在记录第一条消息之前不打开该套接字。
LOG_NOWAIT 不要等待在将消息记入日志过程中可能已创建的子进程.因为在syslog调用wait时,应用程序可能已经获得了子进程的状态,这种处理阻止了与捕获SIGCHLD信号应用程序之间产生的冲突。(syslog调用应该会创建子进程)
LOG_ODELAY 在第一条消息被记录之前延迟打开至syslogd守护进程的连接。
LOG_PERROR 除将日志消息发送至syslogd以外,还将它写至标准错误。
LOG_PID 记录每条消息都要包含进程ID。此选项可供对每个不同的请求都fork一个子进程的守护进程使用。

openlogfacility参数值取自下图:

facility and level

设置facility参数的目的是可以让配置文件说明,来自不同设置的消息将以不同的方式进行处理。

调用syslog产生一个日志消息。其priority参数是facilitylevel的组合。level见上图。

format参数以及其他所以参数传至vsprintf函数以便进行格式化。在format中,每个出现的%m字符都将先被代换为与erron值对于的出错消息字符串(strerror)。

setlogmask函数用来设置进程的记录优先级屏蔽字。它返回调用它之前的屏蔽字。当设置了记录优先级屏蔽字时,各条消息除非已经在记录优先级屏蔽字中进行了设置,否则不会被记录。

实例:

在一个(假定的)行式打印机假脱机守护进程中,可能包含有下面的调用序列:

1
2
openlog("lpd", LOG_PID, LOG_LPR);
syslog(LOG_ERR, "open error for %s: %m", filename);

第一个调用将ident字符串设置为程序名,指定该进程ID要始终被打印,并且将系统默认的facility设定为行打印机系统。对syslog的调用指定一个出错条件和一个消息字符串。如果不调用openllog,则第二个调用形式可能是:

1
syslog(LOG_ERR | LOG_LPR, "open error for %s: %m",filename);

其中将priority参数被指定为level和facility的组合。

单实例守护进程

为了正常运作,某些守护进程会实现为,在任一时刻只运行该守护进程的一个副本。例如,这种守护进程可能需要排它的访问一个设备。

如果一个守护进程需要访问一个设备,而该设备驱动程序有时会阻止想要多次打开/dev目录下相应设备节点的尝试。这就限制了在一个时刻只能运行守护进程的一个副本。但如果没有终止设备可供使用,那么我们需要自己处理来保证任一时刻只运行该守护进程的一个副本。

文件和记录锁机制为一中方法提供了基础,该方法保证一个守护进程只有一个副本在运行。(具体在下一章讨论)如果每一个守护进程创建一个有固定名字的文件,并在该文件的整体上加一把锁,那么只允许创建一把这样的写锁。在此之后创建写锁的尝试都会失败,这向后续守护进程的副本指明已有一个副本正在运行。

文件和记录锁提供了一种方便的互斥机制。如果守护进程在一个文件的整体上得到一把写锁,那么在该守护进程终止时,这把锁自动删除。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include<unistd.h>
#include<stdio.h>
#include<fcntl.h>
#include<syslog.h>
#include<string.h>
#include<errno.h>
#include<stdio.h>
#include<sys/stat.h>
#include<stdlib.h>

#define LOCKFILE "/var/run/daemon.pid"
#define LOCKMDE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)

extern int lockfile(int);

int already_running(void)
{
int fd;
char buf[16];

fd = open(LOCKFILE, O_RDWR | O_CREAT, LOCKMDE);
if(fd<0)
{
syslog(LOG_ERR, "can't open %s: %s", LOCKFILE, strerror(errno));
exit(0);
}

if(lockfile(fd)<0)
{
if(errno == EACCES || errno == EAGAIN)
{
close(fd);
return 1;
}
syslog(LOG_ERR, "can't lock %s: %s", LOCKFILE, strerror(errno));
exit(1);
}
ftruncate(fd, 0);
sprintf(buf, "%ld", (long)getpid());
write(fd, buf, strlen(buf)+1);
return 0;
}

守护进程的每个副本试图创建一个文件,并将其进程ID写到该文件中。如果该文件已经加锁,那么lockfile函数将失败,erron将被设置为EACESSEAGAIN,函数返回1,表示该守护进程存在一个副本在运行。否则将文件长度截断为0,将进程ID写入该文件。将文件截断为0的原因是,之前的进程ID可能长于当前的进程ID,如之前是12345,现在是9999,则如果不截断,则会变成99995。

守护进程

UNIX中,守护进程遵循下列通用惯例:

  1. 若守护进程使用锁文件,那么该文件通常存储在/var/run目录中。守护进程需要超级用户权限才能在此文件夹下创建文件。锁文件的名字通常是name.pid,其中name是该守护进程或者访问的名字。
  2. 若守护进程支持配置选项,那么配置文件通常放在/etc目录中。配置文件的名字通常是name.conf,其中name是该守护进程或者访问的名字。
  3. 守护进程可用命令行启动,但通常它们是由系统初始化脚本之一(/etc/re*/etc/init.d/*)启动的。如果在守护进程终止时,应当自动地重新启动它,则我们可用在/etc/inittab中为该进程包括respawn记录项,这样init就会重新启动该进程。
  4. 若一个守护进程有一个配置文件,那么当该守护进程启动时会读该文件,但在此之后一般不会再查看它。若某个管理员更改了配置文件,那么该守护进程可能需要被停止,然后在启动,以使配置文件生效。为了避免这种麻烦,某些文件将捕捉SIGHUP信号,当它们接收到信号时,重新读取配置文件。因为守护进程并不与终端相结合,它们或者是无终端的会话首进程,或者是孤儿进程组的成员,所以守护进程没有理由期望接收到SIGHUP,因此可以安全地重复使用SIGHUP

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#include"apue.h"
#include<pthread.h>
#include<syslog.h>

sigset_t mask;

extern int already_running(void);

void reread(void)
{
/*...*/
}

void *thr_fn(void *arg)
{
int err, signo;
while(1)
{
//wait signal which included mask
err = sigwait(&mask, &signo);
if(err!=0)
{
syslog(LOG_ERR, "sigwait failed");
exit(1);
}
switch(signo)
{
case SIGHUP:
syslog(LOG_INFO, "Re-reading configuration file");
reread();
break;
case SIGTERM:
syslog(LOG_INFO, "get SIGTERM;exiting");
exit(0);
default:
syslog(LOG_INFO, "unexpected signal %d\n", signo);
}
}
return 0;
}

int main(int argc, char *argv[])
{
int err;
pthread_t tid;
char *cmd;
struct sigaction sa;
if((cmd = strrchr(argv[0],'/')) == NULL)
{
cmd = argv[0];
}
else{
cmd++;
}

/* become daemon */
daemonize(cmd);

/* ensure only one copy of the daemon is running*/

if(already_running())
{
syslog(LOG_ERR, "daemon already running");
exit(1);
}

/* restore SIGHUP default and block all signals*/

sa.sa_handler = SIG_DFL;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if(sigaction(SIGHUP, &sa, NULL)<0)
{
err_quit("%s: can't restore SIGHUP default");
}
sigfillset(&mask);
if((err = pthread_sigmask(SIG_BLOCK, &mask, NULL))!=0)
{
err_exit(err, "SIG_BLOCK error");
}

/* create a pthread to handle SIGHUP and SIGTERM*/

err = pthread_create(&tid,NULL,thr_fn, (void*)0);
if(err!=0)
{
err_exit(err, "can't create thread");
}
/* process with the rest of the daemon*/

exit(0);
}

这里使用创建了一个线程专门用来处理信号,当然也可以使用一个单线程守护进程来实现。

客户进程-服务进程模型

守护进程通常用服务器进程。用户进程用UNIX域数据报套接字向其发送消息。一般而言,服务器进程等待客户进程与其连续,提出某种类型的服务请求。

在服务器进程中调用fork然后exec另一个程序来向客户进程提供服务是很常见的。这些服务器进程通常管理者多个文件描述符:通信端点、配置文件、日志文件和类似的问价。最好的情况下,让子进程中的这些文件描述符保持打开状态并无大碍,因为在子进程中很可能用不到。最坏情况下,保持打开可能会导致安全问题:被执行程序可能有一些恶意行为,如更改服务器配置文件或欺骗客户端程序使其认为正在与服务器通信,从而获取未授权的信息。

为了解决此问题的一个简单方式是对所以被执行的文件描述符设置执行时关闭,可以使用如下的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include"apue.h"
#include<fcntl.h>

int set_cloexec(int fd)
{
int val;
if((val = fctl(fd, FD_GETFD, 0))<0)
{
return -1;
}
val |= FD_CLOEXEC;
return fctl(fd, F_SETFD, val);
}

第十四章 高级I/O

非阻塞I/O

系统调用分为两类:低速系统调用和其他系统调用。低速系统调用是可能会使进程永远阻塞的一类系统调用(第十章):

  1. 如果某些类型文件(如读管道、终端设备和网路设备)的数据不存在,则读操作可能会使调用者永远阻塞。
  2. 如果这些数据不能被相同类型的文件立即接受,则写操作可能会使调用者永远阻塞。
  3. 在某些条件发生之前打开某些文件,可能会发生阻塞(例如打开一个终端设备,需要先等待与之连接的调制解调器应答)。
  4. 对已经加上强制性记录锁的文件进行读写。
  5. 某些ioct1操作。
  6. 某些进程间通信函数。

虽然读写磁盘的操作会暂时阻塞调用者,但不能将与磁盘相关I/O有关的系统调用视为低速。

非阻塞I/O使我们可以发出openreadwrite这样的I/O操作,并使这些操作不会永远阻塞。如果这种操作不能完成,则调用者立即出错返回,表示该操作若继续执行将阻塞。

对于一个给定的描述符,有两种方式为其指定非阻塞I/O的方法:

  1. 如果调用open获得描述符,则可以指定O_NONBLOCK标志。
  2. 对于已经打开的文件描述符,则可以调用fcntl,由该函数打开O_NONBLOCK文件状态标志。

例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include"apue.h"
#include<errno.h>
#include<fcntl.h>

char buf[500000];

void set_fl(int fd, int flags)
{
int val;
if(val = fcntl(fd, F_GETFD, 0)<0)
{
err_sys("fcntl F_GETFD error");
}
val |= flags;
if(fcntl(fd, F_SETFL, val)<0)
{
err_sys("fct1 F_SETFL error");
}

}

void clr_fl(int fd, int flags)
{
int val;
if(val = fcntl(fd, F_GETFD, 0)<0)
{
err_sys("fcntl F_GETFD error");
}
val &= (~flags);
if(fcntl(fd, F_SETFL, val)<0)
{
err_sys("fct1 F_SETFL error");
}

}

int main(void)
{
int ntowrite, nwrite;
char *ptr;
ntowrite = read(STDIN_FILENO, buf, sizeof(buf));
fprintf(stderr, "read %d byte\n", ntowrite);

set_fl(STDOUT_FILENO, O_NONBLOCK);

ptr = buf;

while (ntowrite > 0)
{
errno = 0;
nwrite =write(STDOUT_FILENO, ptr, ntowrite);
fprintf(stderr, "nwrite = %d, errno=%d\n", nwrite, errno);

if(nwrite>0)
{
ptr += nwrite;
ntowrite -= nwrite;
}
}

clr_fl(STDOUT_FILENO, O_NONBLOCK);
exit(0);
}

运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ ls -l /etc/services 
-rw-r--r-- 1 root root 19183 12月 26 2016 /etc/services

##输出到指定文件
$ ./14_2.o < /etc/services > temp.file
read 19183 byte
nwrite = 19183, errno=0

## 输出到终端
$ ./14_2.o < /etc/services 2>stderr.out
.....
.....
.....
$ cat stderr.out
read 19183 byte
nwrite = 18667, errno=0
nwrite = -1, errno=11
nwrite = -1, errno=11
.
.
.
.

nwrite = -1, errno=11
nwrite = -1, errno=11
nwrite = 516, errno=0
chst@wyk-GL63:~/study_file/unix编程$

在向终端输出的过程中,发出了大量write调用,但只有2个产生了真正的输出,其余都返回了错误。这种形式的循环称为轮询,在多用户系统上会浪费CPU时间。

记录锁

fcntl记录锁

记录锁的功能是:当第一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。

fcntl记录锁:

1
2
3
#include<fcntl.h>
int fcntl(int fd, int cmd, .../*struct flock *flockptr */);
//成功,返回依赖于cmd,否则返回-1

对于记录锁,cmd是F_GETLK, F_SETFL, F_SETLKW。第三个参数是一个指向flock结构的指针:

1
2
3
4
5
6
7
struct flock{
short l_type; /*F_RDLCK, F_WRLCK, or F_UNLCK */
short l_whence; /* SEEK_SET, SEEK_CUR, or SEEK_END*/
off_t l_start; /*offset in bytes, relative to l_whence*/
off_t l_len; /*length , in byte, 0 mean lock to EOF*/
pid_t l_pid; /*return with F_GETLK*/
}

flock结构说明如下:

  1. 所希望锁类型: F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)或F_UNLCK(解锁一个区域)。
  2. 要加锁或解锁区域的起始字节偏移量(l_startl_whence)。
  3. 区域的字节长度。
  4. 进程ID(l_pid)持有的锁能阻塞当前进程(仅由F_GETLK返回)。

锁可以在当前文件开始或者越过尾端处开始,但不能在文件起始之前开始。如若l_len为0,则表示锁的范围可以扩展到最大可能偏移量。这意味着不管向该文件中追加了多少数据,他们都可以处于锁的范围内,而且起始位置可以是文件终端任意位置。

共享读锁和独占写锁基本规则是:任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定字节上只能有一把独占写锁。这个规则只适用于多个进程,并不适用于单个进程的多个锁请求。如果一个进程对一个文件区域已经有一把锁,后来该进程又企图在同一个文件区域再加一把锁,那么新的锁将会替换已有的锁。

加读锁时,该描述符必须是读打开的。加写锁时,该描述符必须是写打开的。

对于fcntl函数的3中命令:

F_GETLK 判断由flockptr所描述的锁是否会被另外一把锁排斥(阻塞)。如果存在一把锁,它阻止创建由flockptr锁描述的锁,则该现有锁的信息将重写flockptr指向的信息。如果不存在这种情况,则除了将l_type设置为F_UNLCK之外,flockptr所指向的信息保持不变。
F_SETLK 设置由flockptr所描述的锁。如果试图获得一把读锁或写锁,而兼容性规制阻止系统给我们这把锁,那么fcntl会立即出错返回,此时errno设置为EACCESEAGAIN
F_SETLKW 该命令是F_SETLK的阻塞版本。如果加锁不能被授权,那么调用进程会被设置为阻塞。如果请求创建的锁已经可用,或者休眠由信号中断,则该进程被唤醒。

F_GETLKF_SETLK之间不是原子操作,因此在执行完第一个查询后不能保证是否有别的进程插入并建立一把相同的锁。如果不希望在等待锁变成可用时产生阻塞,就必须处理由F_SETLK返回的可能错误。

实例:请求和释放一把锁

1
2
3
4
5
6
7
8
9
10
11
12
13
#include"apue.h"
#include<fcntl.h>

int lock_reg(int fd, int cmd, int type, off_t offset, int whenec, off_t len)
{
flock lock;
lock.l_len = len;
lock.l_start = offset;
lock.l_type = type;
lock.l_whence = whenec;

return fcntl(fd, cmd, &lock);
}

apue.h中定义了五个宏:

1
2
3
4
5
6
7
8
9
10
#define	read_lock(fd, offset, whence, len) \
lock_reg((fd), F_SETLK, F_RDLCK, (offset), (whence), (len))
#define readw_lock(fd, offset, whence, len) \
lock_reg((fd), F_SETLKW, F_RDLCK, (offset), (whence), (len))
#define write_lock(fd, offset, whence, len) \
lock_reg((fd), F_SETLK, F_WRLCK, (offset), (whence), (len))
#define writew_lock(fd, offset, whence, len) \
lock_reg((fd), F_SETLKW, F_WRLCK, (offset), (whence), (len))
#define un_lock(fd, offset, whence, len) \
lock_reg((fd), F_SETLK, F_UNLCK, (offset), (whence), (len))

实例:测试一把锁

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

pid_t lock_test(int fd, int type, off_t offset, int whence, off_t len)
{
flock lock;
lock.l_whence = whence;
lock.l_type = type;
lock.l_start = offset;
lock.l_len = len;

if(fcntl(fd, F_GETLK, &lock)<0)
{
err_sys("fcntl error");
}

if(lock.l_type == F_UNLCK)
{
return 0; /* false region isn't locked by another proc*/
}
return lock.l_pid; /*true, return pid of lock owner*/
}

apue.h中定义了两个宏:

1
2
3
4
#define	is_read_lockable(fd, offset, whence, len) \
(lock_test((fd), F_RDLCK, (offset), (whence), (len)) == 0)
#define is_write_lockable(fd, offset, whence, len) \
(lock_test((fd), F_WRLCK, (offset), (whence), (len)) == 0)

注意:进程不能使用lock_test函数来测试它自己是否在文件的某一部分持有锁。F_GETLK定义说明,返回信息指示是否现有的锁会阻止调用进程获取自己的锁。因为F_SETLKF_SETLKW命令总是替换调用进程现有的锁,所以调用进程不会阻塞在自己持有的锁上。

实例:死锁。

当两个进程相互等待对方持有并且不释放的资源时,则两个进程进入死锁状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include"apue.h"
#include<fcntl.h>

static void lockabyte(const char *name, int fd, off_t offset)
{
if(write_lock(fd, offset, SEEK_SET, 1)<0)
{
err_sys("%s: write_lock error", name);
}
printf("%s: got the lock, byte %lld\n", name, (long long)offset);
}

int main(void)
{
int fd;
pid_t pid;

if((fd = creat("templock", FILE_MODE))<0)
{
err_sys("create file error");
}
if(write(fd, "ab", 2)!=2)
{
err_sys("write to file error");
}

TELL_WAIT();
if((pid = fork())<0)
{
err_sys("fork error");
}
else{
if(pid>0)
{
lockabyte("parent", fd, 0);
TELL_CHILD(pid);
WAIT_CHILD();
lockabyte("parent", fd, 1);
}
else{
lockabyte("child", fd, 1);
TELL_PARENT(getppid());
WAIT_PARENT();
lockabyte("child", fd, 0);
}
}
exit(0);
}

执行结果:

1
2
3
4
5
$ ./14_7.o
parent: got the lock, byte 0
child: got the lock, byte 1
child: write_lock error: Resource temporarily unavailable
parent: write_lock error: Resource temporarily unavailable

这里可以看到,父进程和子进程都无法获得锁,陷入死锁状态。

锁的隐含继承和释放

锁的自动继承和释放有3条规则:

(1)锁与进程和文件两者相关联。有两层含义:第一个是当进程终止时,其所建立的锁全部释放。第二是无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放。因此:

1
2
3
4
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = dup(fd1);
close(fd2);

close(fd2)后在f1上设置的锁被释放。将dup换成open也是一样的。

(2)由fork产生的子进程不继承父进程所设置的锁。

(3)在执行exec后,新程序可以继承原执行的锁。但如果一个文件描述符设置了执行时关闭,那么exec后,会关闭文件同时释放锁。

FreeBSD实现

考虑进程执行下面语句:

1
2
3
4
5
6
7
8
9
10
11
12
fd1 = open(pathname, ...);
write_lock(fd1, 0, SEEK_SET, 1);
if((pid = fork()) >0)
{
fd2 = dup(fd1);
fd3 = open(pathname, ...);
}
else if(pid == 0)
{
read_lock(fd1, 1, SEEK_SET, 1);
}
pause();

下图展示了运行到pause时数据结构:

pause

这里在原来图的基础上添加了lockf结构,它们由i节点结构开始互相连接起来。每个lockf结构描述一个给定进程的一个加锁区域(由偏移量和长度决定)。在父进程中,关闭fd1fd2fd3中任意一个都会释放由父进程设置的写锁。在关闭这三个其中一个时,内核会从该描述符关联的i节点开始,逐个检查lockf链表中各项,并释放由该调用进程锁持有的各把锁。

实例:在单实例守护进程中我们使用lockfile函数来保证只有该守护进程的唯一副本在运行,下面给出其函数实现:

1
2
3
4
5
6
7
8
9
int lockfile(fd)
{
flock f1;
f1.l_type = F_WRLCK;
f1.l_start = 0;
f1.l_whence = SEEK_SET;
f1.l_len = 0;
return fcntl(fd, F_SETLK, &f1);
}

在文件末尾加锁

在获取从某个位置到文件末尾的锁时,不能简单的使用fstat函数来获取文件长度来进行加锁,因为在fstat之后和加锁之前,可能存在别的进程改变该文件。因此一般是指定长度为0,此时可获得到文件末尾的锁。但是考虑如下代码:

1
2
3
4
writew_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1);
un_lock(fd, 0, SEEK_END);
write(fd, buf, 1);

该代码获取一把写锁,该写锁从当前文件末尾起,包括以后可能追加写到该文件的任何数据。当文件偏移量处于末尾时,执行第一个写,该操作将文件延长1个字节,且被加锁。随后的解锁操作是对以后追加写到文件上的数据不加锁。但之前写的一个字节则保留加锁状态。写第二个字节时,文件末尾又延伸一个字节,但未加锁。想要删除所以锁,应该使用un_lock(fd, -1, SEEK_END);。这里-1是相对偏移量,表示相对末尾的前一个字节。

建议性锁和强制性锁

强制性锁会让一内核检测每一个openreadwrite,验证调用进程是否违背了正在访问的文件上的一把锁。强制性锁有时也被称作强迫方式锁。

这里建议性锁就是我们之前所叙述的锁,我们使用锁来保证读写时不与其他进程冲突。而强制性锁是相对于某个进程不使用锁来保证访问冲突时发生的情况,即,一些进程使用了锁,而另一些进程根本没想过要使用锁而直接对文件进行打开、读取和写的操作时会发生什么情况。对于建议性锁来说,可以正常执行,但可能导致进程间混乱冲突。对于强制性锁,如果有的进程已经又了该锁,而且按照规则当前进程不应该进行相关操作却进行时,会产生错误。这两者之间的比较看该节最后的例程会有更加深入的理解。

对一个特定文件,打开其设置组ID位、关闭其组执行位便开启了对该文件的强制性锁机制。因为当关闭组执行位时,设置组ID位将不在有意义。

当一个进程试图读写一个强制性锁起作用的文件时,下图展示了其可能情况:

state

除了对readwrite函数产生影响,另一个进程持有的强制性锁也会对open函数产生影响。如果要打开的文件具有强制性记录锁,而且open调用的标识是O_TRUNCO_CREAT,则不论是否指定O_NONBLOCKopen都立即出错返回,error设置为EAGAIN

Linux使用strace命令可以得到一个进程的系统调用跟踪信息。Linux如果用户想要使用强制性锁,需要在各个文件系统基础上用mount命令的-o mand选项来打开。

例程:确定一个系统是否支持强制性锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include"apue.h"
#include<errno.h>
#include<fcntl.h>
#include<sys/wait.h>

int main(int argc, char *argv[])
{
int fd;
pid_t pid;
char buf[5];
struct stat statbuf;
if(argc != 2)
{
fprintf(stderr, "usage:%s filename\n", argv[0]);
exit(1);
}

if((fd = open(argv[1], O_RDWR | O_CREAT |O_TRUNC, FILE_MODE))<0)
{
err_sys("open error");
}

if(write(fd, "abcdef", 6)!=6)
{
err_sys("write error");
}

/*turn on set-group-id and turn off group-execute*/
if(fstat(fd, &statbuf)<0)
{
err_sys("fstat error");
}

if(fchmod(fd, (statbuf.st_mode & ~S_IXGRP) | S_ISGID)<0)
{
err_sys("fchmod error");
}

TELL_WAIT();

if((pid = fork())<0)
{
err_sys("fork error");
}
else if(pid>0)
{
if(write_lock(fd, 0, SEEK_SET, 0)<0)
{
err_sys("write_lock error");
}
TELL_CHILD(pid);
if(waitpid(pid, NULL, 0)<0)
{
err_sys("waitpid error");
}
}
else{
WAIT_PARENT();
set_fl(fd, O_NONBLOCK);

if(read_lock(fd, 0, SEEK_SET, 0)!=-1)
{
err_sys("child: read_lock succeed");
}
printf("read_lock of alread-locked region return %d\n", errno);

if(lseek(fd, 0, SEEK_SET) == -1)
{
err_sys("lseek error");
}
if(read(fd, buf, 2)<0)
{
err_ret("read faild (mandatory locking works)");
}
else{
printf("read ok (no mandatory locking), buf=%2.2s\n", buf);
}
}
exit(0);
}

在linux未打开强制性锁机制时:

1
2
3
$ ./edit temp.lock
read_lock of alread-locked region return 11
read ok (no mandatory locking), buf=ab

目前还没有成功打开强制性锁机制(哭了)。

I/O多路转接

函数select和pselect

select函数使我们可以执行I/O多路转接。传递给select的参数告诉内核:

  1. 我们所关心的描述符。
  2. 对于每个描述符我们关系的条件(从其读,向其写,发生异常)。
  3. 愿意等待时间。

select返回时,内核告诉我们:

  1. 已准备好的描述符数量。
  2. 对于读、写或异常这三个条件中的每一个,哪些描述符已经准备好了。

函数原型:

1
2
3
#include<sys/select.h>
int select(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict execptfds, struct timeval *restrict tvptr);
// 返回值:准备就绪的数量,若超时返回0,若出错,返回-1

tvptr指定等待时间。当tvptr是NULL时,永远等待。如果捕捉到一个信号则中断此无限等待。当所指定的文件描述符中的一个已经准备好或者捕捉到一个信号则返回。当tvptr->tv_sec == 0 && tvptr->tv_usec == 0不等待直接返回。否则等待指定的时间。

中间三个参数readfds、writefds、execptfds是指向描述符集的指针。这三个描述符集说明了我们所关心的读写或异常描述符集。每个描述符集存储在一个fd_set数据类型中。它可以为每一个可能的描述符保持一位,我们可以认为其是很大的数组。

对于fd_set数据类型,唯一可以进行的处理是:分配一个这种类型的变量,将这种类型的变量赋值给另一个变量,或对这种变量使用下面的函数:

1
2
3
4
5
6
7
#include<sys/select.h>
int FD_ISSET(int fd, fd_set *fdset);
//如果fd在描述符集中,返回非0, 否则返回0

void FD_CLR(int fd, fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);

申明一个描述符集时,必须使用FD_ZERO函数将其置0,然后设置我们关心的各个描述符位,如:

1
2
3
4
5
fd_set rset;
int fd;
FD_ZERO(&rset);
FD_SET(fd, &fset);
FD_SET(STDIN_FILENO, &rset);

当从select返回时,应该使用FD_ISSET测试该集中的一个给定位是否仍处于打开状态:

1
2
if(FD_ISSET(fd, &rset))
.....

select中间的三个参数任意一个都可以是NULL,空表示并没有要关心的描述符。当三个都是NULL时,select退化成sleep,不过提供了更高的精度。

select第一个参数maxfdp1意思是“最大文件描述符值加1”。考虑在3个文件描述符集中最大的文件描述符值,然后加1.也可以设置为FD_SETSIZE,这是<sys/select.h>中一个常值。该参数指定了select搜索范围,如果远远大于我们实际使用的文件描述符最大值加1将会造成浪费。因为文件描述符从0开始,因此要加一。第一个参数实际指定了要检测的文件描述符数量。

select有三个可能返回值:

(1)返回值-1表示出错。

(2)返回0表示没有描述符准备好。

(3)一个正值表示准备好的描述符数量。

准备好的含义是:

  1. 对于读集中的一个描述符进行读操作不会阻塞,表示是准备好的。
  2. 对于写集中的一个描述符进行写`操作不会阻塞,表示是准备好的。
  3. 对于异常条件集中的一个描述符有一个未决异常条件,则认为此描述符是准备好的。现在,异常条件包括:在网络连接上到达带外的数据,或者处于数据包模式的伪终端上发生了某些条件。
  4. 对于读写和异常条件。普通文件的文件描述符总是返回准备好。
  5. 一个文件描述符阻塞与否不影响select是否阻塞。

如果一个描述符碰到了文件末尾,则select会认为该描述符时可读的。然后调用read它返回0,这是UNIX系统指示到达文件末尾的方式。

POSIX.1也定义了一个select的变体:

1
2
3
4
#include<sys/select.h>

int pselect(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict execptfds, const struct timespec *retrict tsptr, const sigset_t *restrict sigmask);
// 返回值:准备就绪的数量,若超时返回0,若出错,返回-1

select之间区别为下面几点:

  1. 超时时间使用类型不一致。
  2. pselect超时时间设置为const保证不会被改变。
  3. pselect可使用信号屏蔽字。若sigmask为NULL,在调用pselectselect一致,否则,sigmask指向的信号屏蔽字将会以原子操作的方式被安装,在返回时,恢复以前的信号屏蔽字。

函数poll

函数原型:

1
2
3
#include<poll.h>
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout)
// 返回值:准备就绪的数量,若超时返回0,若出错,返回-1

pollfd结构指定一个描述符编号以及我们对该描述符感兴趣的条件:

1
2
3
4
5
struct pollfd{
int fd;
short events;
short reevents;
}

fdarray数组中的元素数由nfds指定。

events成员设置为下表中的一个或几个,通过这些值告诉内核我们关系的是描述符的哪些事件。返回时,revents成员由内核设置,用于说明每个描述符发生了那些事件。

(r)events

前四行测试可读性,后面三行测试可写性,最后三行测试异常条件。最后三行由内核返回时设置。即使events未指定这三个值,如果响应条件发生,在revents中也会返回它们。

当一个描述符被挂断(POLLHUP)后,不能再写该描述符,但是有可能任然可以从该描述符读取数据。

poll最后一个参数指定了我们愿意等待的时间,其为毫秒。-1表示永久等待,0表示不等待,>0表示等待对应毫秒。

文件尾端和挂断区别,如果我们向终端输入数据,并键入文件结束符,那么就会打开POLLIN,于是我们可以读文件结束指示(read返回0)。revents中的POLLHUP并未被打开。如果正在读调制解调器,并且电话线已挂断,我们将接收到POLLHUP

selectpoll由于信号造成的中断一般不会重启。

异步I/O

POSIX异步I/O接口为对不同类型的文件进行异步I/O提供了一套一致的方法。这些异步接口使用AIO控制块来描述I/O操作。aiocb结构定义了AIO控制块。该结构至少包含下面的字段:

1
2
3
4
5
6
7
8
9
struct aiocb{
int aio_filedes; /* file descriptor */
off_t aio_offset; /* file offset for I/O */
volatile void *aio_buf; /* buffer for I/O*/
size_t aio_nbytes; /* number of bytes to transfer */
int aio_reqprio; /* priority */
struct sigevent aio_sigevent; /*signal information*/
int aio_lio_opcode; /*operation for list I/O*/
}

aio_fileds字段表示被打开用来读写的文件描述符。读写操作从aio_offset指定的偏移量开始。对于读操作,数据会复制到缓冲区,该缓冲区从aio_buf指定的地址开始。对于写操作,数据从这个缓冲区中复制出来。aio_nbytes字段包含了要读写的字节数。

异步I/O必须显示的指定偏移量。异步I/O并不影响由操作系统维护的文件偏移量。不能在同一进程里把异步I/O和传统I/O函数混在一起。如果异步I/O接口向一个以追加模式打开的文件写入数据,AIO控制模块的aio_offset字段会自动忽略。

应用程序使用aio_reqprio字段为异步I/O请求提示顺序(建议性,非强制)。aio_lio_opcode字段只能用于基于列表的异步I/O。aio_sigevent控制在I/O完成后,如何通知应用程序。改字段对于结构sigevent为:

1
2
3
4
5
6
7
struct sigevent{
int sigev_notify; /* notify type*/
int sigev_signo; /* signal number*/
union sigval sigev_value; /notify argument*/
void (*sigev_notify_function)(union sigval); /*notify function*/
pthread_attr_t *sigev_notify_attributes; /*notify attrs*/
}

sigev_notify控制通知类型。取值为下面三个中一个。

SIGEV_NONE 异步I/O请求完成后,不通知进程。
SIGEV_SIGNAL 异步I/O请求完成后,产生由sigev_signo字段指定的信号。如果应用程序已选择捕捉信号,且在建立信号处理程序时指定了SA_SIGINFO标志,那么该信号将被入队(如果支持排队信号)。信号处理程序会传送给一个siginfo结构,该结构的si_value字段被设置为sigev_value
SIGEV_THREAD 异步I/O请求完成时,由sigev_notify_function字段指定的函数被调用。sigev_value字段被传入作为它的唯一参数。除非sigev_notify_attributes字段被设置为pthread属性结构的地址,且该结构指定了一个另外的线程属性,否则该函数将在分离状态下的一个单独的线程中执行。

下面的函数用来实现异步读写:

1
2
3
4
#include<aio.h>
int aio_read(struct aiocb *aiocb);
int aio_write(struct aiocb *aiocb);
//成功返回0,否则返回-1

当这些函数成功返回时,异步I/O请求便已经被操作系统放入等待队列中。返回值与实际I/O结果没有关系。I/O操作在等待时,必须确保AIO控制块和数据库缓冲区保持稳定,它们下面对应的内存必须是始终合法的,除非I/O操作完成,否则不能复用。

要想强制所以等待中的异步操作不等待而写入持久化的存储中,可以调用aio_fsync函数:

1
2
3
#include<aio.h>
int aio_fsync(int op, struct aiocb *aiocb);
//成功返回0,否则返回-1

op参数设定为O_DSYNC,那么操作执行起来就会像调用了fdatasync一样,否则,如果指定opO_SYNC,那么执行操作就会像调用了fsync

下面的函数可以获得异步读写或者同步操作的完成状态:

1
2
#include<aio.h>
int aio_error(const struct aiocb *aiocb);

函数返回值为:

0 异步操作成功完成。需要调用aio_return函数获取操作返回值。
-1 aio_error的调用失败。erron会标识原因。
EINPROGRESS 异步读写或同步操作任然在等待。
其它 异步操作失败返回的错误码。

异步操作成功调用aio_return获得异步操作返回值:

1
2
#include<aio.h>
ssize_t aio_return(const struct aiocb *aiocb);

直到异步操作完成之前,都需要小心不要调用aio_return函数。每个异步操作只调用一次aio_return。一旦调用了该函数,操作系统就可以释放掉包含I/O操作返回值的记录。

如果aio_return函数本身失败,则返回-1,并设置error。其他情况下,返回readwritefsync在被成功调用时可能返回的结果。

如果在完成所以事物时,还有异步操作未完成,可以调用aio_suspend函数来阻塞进程,直到操作完成:

1
2
3
#include<aio.h>
int aio_suspend(const struct aiocb *const list[], int nent, const struct timespec *timeout);
//成功返回0,否则返回-1

aio_suspend可能返回三种情况中的一种。如果被信号中断,返回-1,并将error设置为EINTR。如果在没有任何I/O操作完成的情况下,阻塞时间超时,返回-1,并将error设置为EAGAIN。如果有任何I/O操作完成,返回0。如果调用aio_suspend时,所以异步I/O操作都完成了,那么直接返回。

list参数是一个指向AIO控制块数组的指针,nent参数表面数组中条目数。空指针会被跳过。

当还有我们不想再完成的等待中的异步I/O操作时,可以尝试使用aio_cancel函数来取消它们:

1
2
#include<aio.h>
int aio_cancel(int fd, struct aiocb *aiocb);

fd指定未完成的异步I/O操作的文件描述符。如果aiocb参数为NULL,系统将会尝试取消所有该文件描述符上未完成的I/O操作。该函数返回值为下面几个:

AIO_ALLDONE 操作在尝试取消之前已经完成。
AIO_CANCELED 所有要求的操作已被取消。
AIO_NOTCANCELED 至少有一个要求的操作未被取消。
-1 函数调用失败,错误码被存储在erron中。

lio_listio函数即能以同步方式来使用,又能以异步方式来使用:

1
2
3
#include<aio.h>
int lio_listio(int mode, struct aiocb *restrict const list[restrict], int nent, struct sigevent *restrict sigev);
//成功返回0,否则返回-1

mode参数决定了I/O是否是异步的。如果是LIO_WAIT,则函数将在由列表指定的I/O操作完成之后返回,此时sigev参数会被忽略。如果mode参数设定为LIO_NOWAIT,则函数将会在I/O请求入队后立即返回。进程将在所以的操作完成后,按照sigev参数指定的,被异步通知。如果不想被通知,则把sigev设置为NULL。每个AIO本身有一个各自操作完成后的异步通知。sigev参数指定的异步通知是在此之外另加的,且只在所以异步操作完成之后才发送。

在每一个AIO控制块中,aio_lio_opcode字段指定了该操作是一个读操作(LIO_READ)、写操作(LIO_WRITE)还是将被忽略的空操作(LIO_NOP)。读操作会将对应的块传递给aio_read处理,写操作给aio_write处理。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
#include"apue.h"
#include<aio.h>
#include<ctype.h>
#include<fcntl.h>
#include<errno.h>

#define BSZ 4096
#define NBUF 8

enum rwop{
UNUSED = 0,
READ_PENDING = 1,
WRITE_PENDING = 2
};

struct buf{
enum rwop op;
int last;
struct aiocb aiocb;
unsigned char data[BSZ];
};

struct buf bufs[NBUF];
unsigned char translate(unsigned char c)
{
if(isalpha(c))
{
if(c >= 'n')
{
c -= 13;
}
else if(c >= 'a')
{
c += 13;
}
else if(c >= 'N')
{
c -= 13;
}
else{
c += 13;
}
}
return c;
}

int main(int argc, char *argv[])
{
int ifd, ofd, i, j, n, err, numop;
struct stat sbuf;
const struct aiocb *aiolist[NBUF];
off_t off = 0;

if(argc != 3)
{
err_quit("usage: rot13 infile outfile");
}

if((ifd = open(argv[1], O_RDONLY))<0)
{
err_sys("can't open %s", argv[1]);
}

if((ofd = open(argv[2], O_WRONLY | O_CREAT| O_TRUNC, FILE_MODE))<0)
{
err_sys("can't open %s", argv[2]);
}

if(fstat(ifd, &sbuf)<0)
{
err_sys("fstat failed");
}

for(i=0;i<NBUF;i++)
{
bufs[i].op = UNUSED;
bufs[i].aiocb.aio_buf = bufs[i].data;
bufs[i].aiocb.aio_sigevent.sigev_notify = SIGEV_NONE;
aiolist[i] = NULL;
}

numop = 0;

while(1)
{
for(i=0;i<NBUF;i++)
{
switch (bufs[i].op){
case UNUSED:
/* read from the input file if more data remain unread*/
if(off<sbuf.st_size)
{
bufs[i].op = READ_PENDING;
bufs[i].aiocb.aio_fildes = ifd;
bufs[i].aiocb.aio_offset = off;
off += BSZ;
if(off >= sbuf.st_size)
{
bufs[i].last = 1;
}
bufs[i].aiocb.aio_nbytes = BSZ;
if(aio_read(&bufs[i].aiocb)<0)
{
err_sys("aio_read failed");
}
aiolist[i] = &bufs[i].aiocb;
numop++;
}
break;
case READ_PENDING:
if((err= aio_error(&bufs[i].aiocb)) == EINPROGRESS)
{
continue;
}
if(err != 0)
{
if(err == -1)
{
err_sys("aio_error failed");
}
else{
err_exit(err, "read failed");
}
}
/* a read is complete; translate the buffer and write it*/
if((n = aio_return(&bufs[i].aiocb))<0)
{
err_sys("aio_return failed");
}
if(n!=BSZ && !bufs[i].last)
{
err_quit("short read(%d/%d)", n, BSZ);
}
for(j = 0; j<n;j++)
{
bufs[i].data[j] = translate(bufs[i].data[j]);
}
bufs[i].aiocb.aio_nbytes = n;
bufs[i].aiocb.aio_fildes = ofd;
bufs[i].op = WRITE_PENDING;
if(aio_write(&bufs[i].aiocb)<0)
{
err_sys("aio_write failed");
}
break;
case WRITE_PENDING:
if((err = aio_error(&bufs[i].aiocb)) == EINPROGRESS)
{
continue;
}
if(err != 0)
{
if(err != -1)
{
err_sys("aio_error failed");
}
else{
err_exit(err, "aio_write failed");
}
}
/* a write is complete , mark the buffer is unused*/
if((n = aio_return(&bufs[i].aiocb))<0)
{
err_sys("aio_return failed");
}

if(n != bufs[i].aiocb.aio_offset)
{
err_quit("short write (%d/%d)", n, BSZ);
}
aiolist[i] = NULL;
bufs[i].op = UNUSED;
numop--;
break;
}
}
if(numop == 0)
{
if(off >= sbuf.st_size)
{
break;
}
}
else{
if(aio_suspend(aiolist, NBUF, NULL)<0)
{
err_sys("aio_suspend failed");
}
}
}

bufs[0].aiocb.aio_fildes = ofd;
if(aio_fsync(O_SYNC, &bufs[i].aiocb)<0)
{
err_sys("aio_fsycn failed");
}
exit(0);

}

这里使用了8个缓冲区,因此可以有8个异步I/O请求。将一个文件的内容经过一个变换,存储到另一个文件中去。

函数readv和writev

readvwritev函数用来在一次函数调用中读写多个非连续缓冲区:

1
2
3
4
#include<sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
//返回值:已读或已写的字节数,若出错,返回-1

函数的第二个参数是指向iovec结构数组的一个指针:

1
2
3
4
struct iovec{
void *iov_base; /*starting address of buffer*/
size_t iov_len; /*size of buffer*/
};

iov数组中元素数由iovec指定。下图显示了这两个函数的参数和iovec结构的关系:

iovec

writev函数从缓冲区中聚集输出数据顺序是:iov[0], iov[1], ...iov[iovcent-1]。返回输出的总字节数,通常等于所以缓冲区长度之和。

readv函数则将读入的数据按照相同顺序散步到缓冲区。readv总是先填满一个再填写下一个。readv返回读到的字节总数。如果遇到文件末尾,无数据可读,则返回0。

函数readn和writen

管道、FIFO以及某些设备(特别是网络和终端)有下列性质:

  1. 一次read操作返回的数据可能少于所要求的数据,即使还未达到文件的末尾也可能出现这种情况。这不是错误,应该继续读该设备。
  2. 一次write操作的返回值也可能少于指定输出的字节数。这也可能是某种原因造成的,例如内核输出缓冲区变满。这也不是错误,应该继续写余下数据。

通常在读写一个管道、网络设备或终端时,需要考虑这些特性。下面的两个函数功能分别是读写指定的N字节数据,并处理返回值小于要求值的情况。这两个函数只是按需要多次调用readwrite直至读写了N字节数据。

1
2
3
4
#include"apue.h"
ssize_t readn(int fd, void *buf, size_t nbytes);
ssize_t writen(int fd, void *buf, size_t nbytes);
//函数返回值:读写字节数,如果出错返回-1

两个函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include"apue.h"
ssize_t readn(int fd, void *ptr, size_t n)
{
size_t nleft;
ssize_t nread;
nleft = n;
while(nleft > 0)
{
if((nread = read(fd, ptr, nleft))<0)
{
if(n == nleft) /*error return*/
return -1;
else{ /* error return amount read so far*/
break;
}
}
else{
if(nread == 0)
{
break; /*EOF*/
}
}
nleft -= nread;
ptr = (void*)((char*)ptr + nread);
}
return n-nleft;
}

ssize_t writen(int fd, const void *ptr, size_t n)
{
size_t nleft;
ssize_t nwritten;

nleft = n;
while(nleft>0)
{
if((nwritten = write(fd, ptr, nleft))<0)
{
if(n == nleft)
{
return -1;
}
else{
break;
}
}
else{
if(nwritten == 0)
{
break;
}
}
ptr = (void *)((char*)ptr+nwritten);
}
return n-nleft;
}

存储映射I/O

存储映射I/O能将一个磁盘文件映射到存储空间的一个缓冲区上,于是当从缓冲区中取数据时,就相当于读文件的相应字节,将数据存入缓冲区时,相应字节就自动写入文件。这样可以在不使用readwrite的情况下执行I/O。

为了使用该功能,应该先告诉内核将一个给定文件映射到一个存储区域中。这由mmap函数实现:

1
2
3
#include<sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flag, int fd, off_t off);
//成功返回映射区起始地址,出错返回MAP_FAILED

addr用于指定映射区域的起始地址。通常将其设置为0,表示由系统选择该存储映射区的起始地址。

fd指定要被映射文件的描述符。在文件映射到地址空间之前必须先打开文件。len参数是映射的字节数。off是要映射字节在文件中的起始偏移量。

port参数指定了映射存储区的保护要求:

port 说明
PROT_READ 映射区可读
PROT_WRITE 映射区可写
PROT_EXEC 映射区可执行
PROT_NONE 映射区不可访问

可将prot参数指定为PROT_NONE,也可指定为PROT_READPROT_WRITEPROT_EXEC三者的按位或。对存储映射区的保护要求不能超过文件open模式访问权限。

存储映射区的实现细节见下图:

mmap

其中起始地址是mmap返回值,映射存储区位于堆和栈之间。

flag参数影响存储映射区的多种属性:

MAP_FIXED 返回值必须等于addr。因为不利于移植,一般不建议使用。
MAP_SHARED 此标志指定存储操作修改映射文件,即存储操作相当于对该文件的write。必须指定本标志或下一个标志,但不能同时指定这两个标志。
MAP_PRIVATE 本标志说明,对映射区的存储操作导致创建该文件的一个私有副本,所以后来对该映射区的引用都是引用该副本。

off的值和addr的值通常被要求是系统虚拟存储页长度的倍数。虚拟存储页长可用带参数_SC_PAGESSIZE_SC_PAGE_SIZEsysconf函数得到。offaddr常常指定为0,所以这种要求一般不重要。

当映射区长度不是页长整数倍时,例如当文件长度为12字节,系统页长512字节,则系统通常提供512字节的映射区,其中后500字节被设置为0。可以修改后面这500字节,但不会体现到文件中。不能使用mmap将数据添加到文件中,必须先加长文件。`

与映射区相关的信号有SIGSEGVSIGBUS。信号SIGSEGV通常用于指示进程试图访问对它不可用的存储区。如储存区是只读的,当向其写时,会产生此信号。如果映射区的某个部分在访问时已经不存在,则产生SIGBUS信号。如用文件长度映射了一个文件,但在映射前,另一个进程已经将该文件截断,此时如果进程试图访问对应于该文件已截去部分的映射区,将会收到SIGBUS信号。

子进程能够通过fork继承存储映射区,但是不能通过exec继承存储映射区。

mprotect函数可以更改一个现有映射的权限:

1
2
3
#include<sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
//成功返回0,出错返回-1

prot的合法值与mmap中一样。如果修改页是通过MAP_SHARED标志映射到地址空间的,那么修改不会立即写回到文件。

如果共享映射的页已被修改,那么可以调用msync将该页冲洗到被映射的文件中:

1
2
3
#include<sys/mman.h>
int msync(void *addr, size_t len, int flags);
//成功返回0,出错返回-1

flags参数使我们对如何冲洗存储区有某种程度的控制。

MS_ASYNC 简单的调试要写的页
MS_SYNC 在返回之前等待写操作完成。与上面的必须指定一个。
MS_INVALIDATE 可选标志,允许我们通知操作系统丢弃那些与底层存储器没有同步的页。

当进程终止时,会自动解除存储映射区的映射,或者直接调用munmap函数来解除映射区。关闭映射存储区时使用的文件描述符并不解除映射区:

1
2
3
#include<sys/mman.h>
int munmap(void *addr, size_t len);
//成功返回0,出错返回-1

munmap并不影响被映射的对象,即调用munmap并不会使映射区的内容写到磁盘。对于MAP_SHARED何时写到磁盘取决于内核调度算法。而对于MAP_PRIVATE存储区的修改会被丢弃。

实例:用存储映射I/O实现文件拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include"apue.h"
#include<fcntl.h>
#include<sys/mman.h>

#define COPYINCR (1024*1024*1024) /* 1GB*/

int main(int argc, char *argv[])
{
int fdin, fdout;
void *src, *dst;
size_t copyze;
struct stat sbuf;
off_t fsz = 0;
if(argc != 3)
{
err_quit("usage: %s<fromfile> <tofile>", argv[0]);
}

if((fdin = open(argv[1], O_RDONLY))<0)
{
err_sys("can't open %s for read",argv[1]);
}

if((fdout = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, FILE_MODE))<0)
{
err_sys("can't creat %s for writing", argv[2]);
}

if(fstat(fdin, &sbuf)<0)
{
err_sys("fstat error");
}

if(ftruncate(fdout, sbuf.st_size)<0)/*set len of output file*/
{
err_sys("ftruncate error");
}

while(fsz<sbuf.st_size)
{
if((sbuf.st_size-fsz)>COPYINCR)
{
copyze = COPYINCR;
}
else{
copyze = sbuf.st_size - fsz;
}

if((src = mmap(0, copyze, PROT_READ, MAP_SHARED, fdin, fsz)) == MAP_FAILED)
{
err_sys("mmap error for input");
}

if((dst = mmap(0, copyze, PROT_READ | PROT_WRITE, MAP_SHARED, fdout, fsz)) == MAP_FAILED)
{
err_sys("mmap error for output");
}

memcpy(dst, src, copyze);
munmap(src, copyze);
munmap(dst, copyze);
fsz += copyze;
}
exit(0);
}

第十五章 进程间通信(IPC)

管道

管道是UNIX最古老的IPC形式。其具有两个局限性:

  1. 是半双工的,即数据只能在一个方向上流动。
  2. 管道只能在公有祖先的两个进程之间使用。通常一个管道由一个进程创建,在进程调用fork之后,这个管道即可在父进程与子进程之间使用。

每当在管道中键入一个命令序列,当shell执行时,shell都会为每一条命令创建一个进程,然后用管道将前一条命令的标准输出与后一条命令的标准输入相连。

管道由pipe函数创建:

1
2
3
#include<unistd.h>
int pipe(int fd[2]);
//成功返回0,出错返回-1

fd返回两个文件描述符:fd[0]为读而打开,fd[1]为写而打开。fd[1]的输出是fd[0]的输入。

下图展示了这种结果:

pipe

fstat函数对管道的每一端都返回一个FIFO类型文件描述符。可以使用S_ISFIFO宏来测试管道。

单个进程中的管道基本没有用,通常是进程调用pipe后,接着调用fork,从而创建父进程到子进程的管道,如下图显示:

pipe

fork之后做什么取决于我们想要的数据流向,对于从父进程到子进程的管道,父进程关闭fd[0],子进程关闭fd[1]。于是得到下图结果:

uU29u4.png

当管道一端被关闭时,下面两条规则起作用:

  1. read一个写端已经被关闭的管道时,在所有数据被读完后,read返回0,表示文件结束。
  2. 如果write一个读端已经关闭的管道,则产生信号SIGPIPE。如果忽略信号或者捕捉该信号并从处理程序返回,则write返回-1,erron设置为EPIPE

写管道时,PIPE_BUF规定了内核管道缓冲区大小。应该保证写的数据小于该值。pathconffpathconf获取该值。

例程:创建从父进程到子进程的管道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include"apue.h"

int main()
{
int n;
int fd[20];
int err;
pid_t pid;
char line[MAXLINE];

if((err = pipe(fd)) < 0)
{
err_sys("pipe error");
}

if((pid = fork())<0)
{
err_sys("fork error");
}
else if(pid == 0)
{
close(fd[1]);
n = read(fd[0], line, MAXLINE);
write(STDOUT_FILENO, line, n);
}
else{
close(fd[0]);
write(fd[1], "hello world\n", 12);
}
exit(0);
}

例程:每次一页的显示已产生的输出,分页功能直接调用已有程序即可,我们只需要向分页程序传递输入数据即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88