"Unix环境高级编程"

  "Unix环境高级编程"

Posted by Xu on June 13, 2018

UNIX环境高级编程









Part1:I/O

一.文件I/O

1.文件描述符

  • STDIN_FILNO(0):标准输入
  • STDOUT_FILENO(1):标准输出
  • STDERR_FILENO(2):标准错误

2.相关调用

2.1 打开文件

unix_ad_1.png

  • 参数:
    • path:要打开或者创建文件的名字
    • oflag:用于指定函数的操作行为:
      • O_RDONLY常量:文件只读打开
      • O_WRONLY常量:文件只写打开
      • O_RDWR常量:文件读、写打开
      • O_EXEC常量:只执行打开
      • O_SEARCH常量:只搜索打开(应用于目录)。本书涉及的操作系统都没有支持该常量

      以上五个常量必须且只能指定一个。下面的常量是可选的(进行或运算)

      • O_APPEND:每次写时都追加到文件的尾端
      • O_CLOEXEC:将FD_CLOEXEC常量设置为文件描述符标志
      • O_CREAT:若文件不存在则创建。使用此选项时,需要同时说明参数mode(指定该文件的访问权限)
      • 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 操作引起的文件属性更新所需的 I/O
      • O_TRUNC: 如果此文件存在,且为O_WRONLY或者O_RDWR成功打开,则将其长度截断为0
      • O_RSYNC:使每一个read操作等待,直到所有对文件同一部分挂起的写操作都完成
      • O_DSYNC:每次 write 等待物理 I/O 完成,但不包括由 write 操作引起的文件属性更新所需的 I/O
    • mode:文件访问权限。文件访问权限常量在 <sys/stat.h> 中定义,有下列九个:
      • S_IRUSR:用户读
      • S_IWUSR:用户写
      • S_IXUSR:用户执行
      • S_IRGRP:组读
      • S_IWGRP:组写
      • S_IXGRP:组执行
      • S_IROTH:其他读
      • S_IWOTH:其他写
      • S_IXOTH:其他执行

对于openat函数,被打开的文件名由fdpath共同决定:

  • 如果path指定的是绝对路径,此时fd被忽略。openat等价于open
  • 如果path指定的是相对路径名,则fd是一个目录的文件描述符。被打开的文件的绝对路径由该fd描述符对应的目录加上path组合而成
  • 如果path是一个相对路径名,而fd是常量AT_FDCWD,则path相对于当前工作目录。被打开文件在当前工作目录中查找。

open/openat 返回的文件描述符一定是最小的未使用的描述符数字

2.2 创建文件

#include<fcntl.h>

int creat(const char *path,mode_t mode);//以只写文件创建文件
  • 参数:
    • path:要创建文件的名字
    • mode:指定该文件的访问权限。文件访问权限常量在 <sys/stat.h> 中定义,有下列九个:
      • S_IRUSR:用户读
      • S_IWUSR:用户写
      • S_IXUSR:用户执行
      • S_IRGRP:组读
      • S_IWGRP:组写
      • S_IXGRP:组执行
      • S_IROTH:其他读
      • S_IWOTH:其他写
      • S_IXOTH:其他执行

该函数等价于open(path,O_WRONLY|O_CREAT|O_TRUNC,mode)

creat的存在一个不足是:它以只写方式打开创建的文件。如果要创建一个临时文件,并要先写该文件,然后又读该文件,则必须先调用createclose,然后再调用open,新版本open出来后,可以以下列方式实现:

open(path,O_RDWR|O_CREAT|O_TRUNC,mode)

2.3 关闭文件

#include<unistd.h>

int close(int fd);

注意:

  • 进程关闭一个文件会释放它加在该文件上的所有记录锁
  • 当一个进程终止时,内核会自动关闭它所有的打开的文件

2.4 定位读写位置

每个文件都有一个与之关联的“当前文件偏移量”。它通常是一个非负整数,用以度量从文件开始处计算的字节数。通常读、写操作都从当前文件偏移量处开始,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量都被置为0

#include<unistd.h>

off_t lseek(int fd,off_t offset,int whence);
  • 参数:
    • fd:打开的文件的文件描述符
    • whence:必须是 SEEK_SETSEEK_CURSEEK_END三个常量之一
    • offset
      • 如果 whenceSEEK_SET,则将该文件的偏移量设置为距离文件开始处offset个字节
      • 如果 whenceSEEK_CUR,则将该文件的偏移量设置为当前值加上offset个字节,offset可正,可负
      • 如果 whenceSEEK_END,则将该文件的偏移量设置为文件长度加上offset个字节,offset可正,可负
  • 打开一个文件时,除非指定O_APPEND选项,否则系统默认将该偏移量设为0
  • 如果文件描述符指定的是一个管道、FIFO、或者网络套接字,则无法设定当前文件偏移量,则lseek将返回 -1 ,并且将 errno 设置为 ESPIPE
  • 对于普通文件,其当前文件偏移量必须是非负值。但是某些设备运行负的偏移量出现。因此比较lseek的结果时,不能根据它小于0 就认为出错。要根据是否等于 -1 来判断是否出错
  • lseek 并不会引起任何 I/O 操作,lseek仅仅将当前文件的偏移量记录在内核中
  • 当前文件偏移量可以大于文件的当前长度。此时对该文件的下一次写操作将家常该文件,并且在文件中构成一个空洞。空洞中的内容位于文件中但是没有被写过,其字节被读取时都被读为0(文件中的空洞并不要求在磁盘上占据存储区。具体处理方式与操作系统有关)

2.5 文件读

#include<unistd.h>

ssize_t read(int fd,void *buf,size_t nbytes);

  • 参数:
    • fd:打开的文件的文件描述符
    • buf:存放读取内容的缓冲区的地址(由程序员手动分配)
    • nbytes:期望读到的字节数

读操作从文件的当前偏移量开始,在成功返回之前,文件的当前偏移量会增加实际读到的字节数

有多种情况可能导致实际读到的字节数少于期望读到的字节数:

  • 读普通文件时,在读到期望字节数之前到达了文件尾端
  • 当从终端设备读时,通常一次最多读取一行(终端默认是行缓冲的)
  • 当从网络读时,网络中的缓存机制可能造成返回值小于期望读到的字节数
  • 当从管道或者FIFO读时,若管道包含的字节少于所需的数量,则 read只返回实际可用的字节数
  • 当从某些面向记录的设备(如磁带)中读取时,一次最多返回一条记录
  • 当一个信号造成中断,而已读了部分数据时,10.5节中再讨论

2.6 文件写

#include <unistd.h>

ssize_t write(int fd,const void *buf,size_t nbytes);
  • 参数:
    • fd:打开的文件的文件描述符
    • buf:存放待写的数据内容的缓冲区的地址(由程序员手动分配)
    • nbytes:期望写入文件的字节数

write的返回值通常都是与nbytes相同。否则表示出错(出错的一个常见原因是磁盘写满,或者超过了一个给定进行的文件长度限制)

对于普通文件,写操作从文件的当前偏移量处开始。如果打开文件时指定了O_APPEND选项,则每次写操作之前,都会将文件偏移量设置在文件的当前结尾处。在一次成功写之后,该文件偏移量增加实际写的字节数

2.7 复制文件描述符

#include <unistd.h>

int dup(int fd);

int dup2(int fd, int fd2);
  • dup:复制文件描述符,返回当前可用文件描述符中的最小值
  • dup2:复制文件描述符,新的文件描述符由fd2指定
    • 若fd2已经打开,则将fd2关闭后复制
    • 若fd2==fd,则直接返回fd2

这两个函数返回的新文件描述符与参数fd共享同一个文件表项:

unix_ad_2.png

复制文件描述符还可以使用fcntl函数来实现:

  • 对于dup,close后使用fcntl(fd,F_DUPFD,0)
  • 对于dup2,close后使用fcntl(fd,F_DUPFD,fd2)

dup2和fcntl的区别:

  1. dup2是一个原子操作,并不等同close()后fcntl,fcntl调用了信号捕获函数,可能修改文件描述符,不同的线程改变了文件描述符也会出现相同的问题
  2. dups和fcntl有一些不同的errno

2.8 函数fcntl

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

#include <fcntl.h>

int fcntl(int fd,int cmd,...)

fcntl有五种功能(出错均返回-1):

  • 参数:
    • fd:已打开文件的描述符
    • cmd:有下列若干种:
      1. 复制一个已有的描述符(还可以使用dupdup2
        • F_DUPFD:复制文件描述符 fd。新文件描述符作为函数值返回。它是尚未打开的文件描述符中大于或等于arg中的最小值。新文件描述符与fd共享同一个文件表项,但是新描述符有自己的一套文件描述符标志,其中FD_CLOEXEC文件描述符标志被清除
        • F_DUPFD_CLOEXEC:复制文件描述符。新文件描述符作为函数值返回。它是尚未打开的个描述符中大于或等于arg中的最小值。新文件描述符与fd共享同一个文件表项,但是新描述符有自己的一套文件描述符标志,其中FD_CLOEXEC文件描述符标志被设置
      2. 获取/设置文件描述符标志
        • F_GETFD:对应于fd的文件描述符标志作为函数值返回。当前只定义了一个文件描述符标志FD_CLOEXEC
        • F_SETFD:设置fd的文件描述符标志为arg
      3. 获取/设置文件状态标志
        • unix_ad_3.png
        • F_GETFL:返回fd的文件状态标志。
          • 获得文件状态标志后,必须首先用屏蔽字 O_ACCMODE 取得访问方式位
          • 然后与O_RDONLYO_WRONLYO_RDWRO_EXECO_SEARCH比较(这5个值互斥,且并不是各占1位)。
          • 剩下的还有:O_APPENDO_NONBLOCKO_SYNCO_DSYNCO_RSYNCF_ASYNCO_ASYNC
        • F_SETFL:设置fd的文件状态标志为 arg
          • 可以更改的标志是: O_APPENDO_NONBLOCKO_SYNCO_DSYNCO_RSYNCF_ASYNCO_ASYNC
      4. 获取/设置异步I/O所有权
        • F_GETOWN:获取当前接收 SIGIOSIGURG信号的进程 ID或者进程组 ID
        • F_SETOWN:设置当前接收 SIGIOSIGURG信号的进程 ID或者进程组 IDarg。若 arg是个正值,则设定进程 ID;若 arg是个负值,则设定进程组ID
      5. 获取/设置记录锁
        • F_GETLKF_SETLKF_SETLKW
    • arg:依赖于具体的命令

unix_ad_4.png

3.进程间文件共享

Unix系统支持在不同的进程中共享打开文件,内核使用三种数据结构描述打开的文件

  1. 进程表项:内核维护一个进程表,其中包含进程ID和对应的进程结构指针,而进程结构task_struct中有一个字段task_struct->files->files_struct->fd_array[]数组结构存放着指向文件对象的指针,数组下标对应着文件描述符。
    • 进程表项其实就是一个数组结构fd_array[]
    • 文件描述符标志
    • 文件表项的指针(文件对象)
  2. 文件表项:内核为所有打开文件维持一张总文件表,一个文件可能被多个进程打开,对应多个不同的文件表项。文件表项维护着是文件和进程之间的交互关系
    • 一个文件表项有文件状态标志
    • 当前文件偏移量
    • 指向该文件v节点表项的指针
  3. 每个打开的文件都有一个v节点结构,v节点包含了文件类型和对这个文件进行各种操作函数的指针。对于大多数文件,v节点还包含该文件的i节点(索引节点,存在磁盘),i节点包含文件的所有者,文件长度,指向文件实际数据块在磁盘所在位置的指针
    • Linux没有使用v节点,而是将v,i节点统一为通用的索引节点结构

unix_ad_5.png

现在假设进程 A 打开文件 file1,返回文件描述符 3;进程 B 也打开文件 file2,返回文件描述符 4:

unix_ad_6.png

内核在文件表上新增两个表项:

  • 这两个文件表项指向同一个 v 结点表项
  • 进程 A 、B 各自的文件描述符表项分别指向这两个文件表项(因此每个进程都有自己的对该文件的当前偏移)

对文件的操作结果:

  • 每次 write 之后,在文件表项中的当前文件偏移量即增加所写入的字节数
    • 若这导致当前文件偏移量超过当前文件长度,则修改 i 节点的当前文件长度,设为当前文件偏移量
  • 如果用 O_APPEND 标志打开一个文件,则相应标志也设置到文件表项的文件状态标志
    • 每次对具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先被置为 i 结点中的文件长度
  • 若用 lseek 定位到文件当前的尾端,则文件表项中的当前文件偏移量设置为 i 结点中的文件长度
    • lseek 函数只是修改文件表项中的当前文件偏移量,不进行任何 I/O 操作

4.原子操作

多个进程写同一文件时,可能产生预想不到的结果。为了避免这种情况,需要理解原子操作

  1. 追加写原子操作
    • 追加写非原子操作
      • lseek到文件末尾
      • write操作
    • 追加写原子操作
      • 指定O_APPEND状态标志位,写即可
  2. 指定位置读写原子操作
    • unix_ad_7.png
  3. 创建一个文件原子操作
    • 创建一个文件时需要先判断该文件是否存在然后创建
      • open判断文件是否存在
      • creat创建文件
    • 创建一个文件原子操作
      • 调用open时指定O_CREAT和O_EXCL选项

数据同步

UNIX操作系统在内核中设有缓冲区高速缓存页高速缓存,大多数磁盘 I/O 都通过缓冲区进行。当我们想文件写入数据时,内核通常都先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式称为延迟写

以下2种情况会将缓冲区中的数据写回到磁盘:

  • 当内核需要重用缓冲区来存方其他数据时,它会把所有延迟写的数据库写入磁盘
  • 可以调用syncfsyncfdatasync来显式的将所有延迟写的数据块写回磁盘
#include<unistd.h>

int fsync(int fd);

int fdatasync(int fd);

void sync(void);
  • 参数(前两个函数):
    • fd:指定写回的文件

3个函数的区别:

  • syncupdate 守护进程会周期性(一般每隔30s)的调用sync函数。命令sync也会调用sync函数):
    • 所有修改过的块缓冲区排入写队列,然后返回
    • 它并不等待实际写磁盘操作结束
  • fsync
    • 只对由fd指定的单个文件起作用
    • 等待写磁盘操作结束才返回
  • fdatasync
    • 只对由fd指定的单个文件起作用,但是它只影响文件的数据部分(fsync会同步更新文件的属性)
    • 等待写磁盘操作结束才返回

五.标准I/O库

1.流

标准I/O库与文件I/O区别:

  • 标准I/O库处理很多细节,如缓冲区分片、以优化的块长度执行I/O等
  • 文件I/O函数都是围绕文件描述符进行。首先打开一个文件,返回一个文件描述符;后续的文件IO操作都使用该文件描述符
  • 标准I/O库是围绕流进行的

当用标准I/O库打开或者创建一个文件时,就有一个内建的流与之相关联

1.1 流的定向

流的定向决定了所读、写的字符是单字节还是多字节的。

当一个流最初被创建时,它并没有定向:

  • 当在未定向的流上使用一个多字节I/O函数,则流的定向为宽定向
  • 当在未定向的流上使用一个单字节I/O函数,则流的定向为字节定向
  • 有两个函数可以改变流的定向:
    • freopen函数清除一个流的定向
    • fwide函数用于设置流的定向
      #include <sttdio.h>
      #include<wchar.h>
    
      int fwide(FILE *fp,int mode);
    
  • 参数:
  • fpFILE文件对象的指针
  • mode:流的定向模式。
    • 如果mode负数,则函数试图使指定的流为字节定向(不保证成功,因为fwide不改变已定向流的定向)
    • 如果mode正数,则函数试图使指定的流为宽定向的(不保证成功,因为fwide不改变已定向流的定向)
    • 如果mode为0,则函数不试图设置流的定向,而直接返回该流定向的值(可以用以获取流的定向)
  • fwide并不改变已定向流的定向
  • fwide无出错
  • 当一个流无效时,我们只能依靠调用fwide前先清除errno,调用后检查errno的值

1.2 FILE对象

  • 当打开一个流时,标准I/O函数会返回一个指向FILE对象的指针。
  • 该对象通常是一个结构体,包含了标准I/O库为管理该流所需要的全部信息。
    • 包括用于实际I/O的文件描述符
    • 指向用于该缓冲区的指针,缓冲区的长度,当前缓冲区中的字符数
    • 出错标志
  • 为了引用一个流,需将FILE指针作为参数传递给每个标准的I/O函数。
  • 我们称指向FILE对象的指针为文件指针

1.3 三个标准流

对一个进程预定义了三个流,这三个流可以自动被进程使用,它们是标准输入,标准输出和标准错误,这三个流对应的FILE对象,均有一个文件指针分别为:stdin,stdout,stderr

2.缓冲

标准I/O库三种类型的缓冲:

  • 全缓冲:此时在标准I/O缓冲区被填满后,标准I/O库才进行实际的I/O操作
  • 行缓冲:此时当输入和输出中遇到换行符时,标准I/O库执行实际的I/O操作。但是注意:
    • 只要填满了缓冲区,即使还没有写一个换行符,也立即进行I/O操作
    • 任何时候只要通过标准I/O库,从一个不带缓冲的流或者一个行缓冲的流得到输入数据,则会冲洗所有行缓冲输出流。(即要缓冲输入,先冲洗输出缓冲)
  • 不带缓冲:标准I/O库不进行缓冲。此时任何I/O都立即执行实际的I/O操作

  • 标准错误都是不带缓冲的,打开至终端设备的流是行缓冲的,其它流都是全缓冲的

下面两个函数可以更改缓冲类型:

#include<stdio.h>

void setbuf(FILE *restrict fp,char *restrict buf);

int setvbuf(FILE *restrict fp,char *restrict buf,int mode,size_t size);
  • 这两个函数必须要在打开流之后调用
  • 参数:
    • fp:被打开的文件对象的指针
    • buf:指向缓冲区的指针(如果是setbuf,长度就是BUFSIZ,定义在<stdio.h>中)
    • mode:指定缓冲类型。可以为:
      • _IOFBF:全缓冲。
      • _IOLBF:行缓冲
      • _IONBF:不带缓冲。此时忽略bufsize参数
    • size:缓冲的长度

unix_ad_8.png

注意:

  • 如果在一个函数内分配一个自动变量类型的标准I/O缓冲区,则从该函数返回之前,必须关闭流。因此自动变量是栈上分配,函数返回之后自动变量被销毁
  • 某些操作系统将缓冲区的一部分存放它自己的管理操作信息,因此可以存放在缓冲区中的实际数据字节数将少于size
  • 通常推荐利用操作系统自动选择缓冲区长度并自动分配缓冲区。在这种情况下若关闭此流,则标准I/O库会自动释放缓冲区

任何时候我们都可以强制冲洗一个流:

#include<stdio.h>

int fflush(FILE *fp);

此函数将该流所有未写的数据都传输到内核,若fp为null,函数将导致所有的输出流都讲被冲洗。

3.流相关的函数调用

3.1 打开流

unix_ad_9.png

  • 参数:
    • type:指定对该I/O流的读写方式(具体见下图)
      • 其中b用于区分二进制文件和文本文件。但是由于UNIX内核并不区分这两种文件,所以在UNIX环境中指定b并没有什么用
      • 对于fdopentype意义稍微有点区别。因为该描述符已经被打开,所以fdopen为写而打开并不截断(w)该文件。另外该文件既然被打开并返回一个文件描述符,则它一定存在。因此标准I/O追加写(a,ab)方式也不能创建文件
    • pathname:待打开文件的路径名
    • fp:指定的流(若fp已经打开,则先关闭该流;若fp已经定向,则清除该定向)
    • fd:指定的文件描述符。获得的标准I/O流将与该描述符结合

对于type的设置如下:

unix_ad_10.png

这几个函数的常见用途:

  • fopen常用于打开一个指定的文件,返回一个文件指针
  • freopen常用于在一个指定的流(标准输入、标准输出或者标准错误)上,打开一个指定的文件
  • fdopen常用于将文件描述符包装成一个标准I/O流。因为某些特殊类型的文件(如管道、socket文件)不能用fopen打开(因为此类文件没有路径名),必须先获取文件描述符,然后对文件描述符调用fdopen

注意:当以读和写类型打开一个文件时(type中带+号的类型),有下列限制:

  • 如果写操作后面没有fflush,fseek,fsetpos,rewind操作之一,则写操作后面不能紧跟读操作
  • 如果读操作后面没有fseek,fsetpos,rewind操作之一,也没有到达文件末尾,则在读操作之后不能紧跟写操作

在指定w或a类型创建一个新文件是,我们无法说明该文件的访问权限位,open和creat可以做到这一点。要求使用如下权限位集来创建文件:

S_IRUSER | S_IWUSR |S_IRGRP |S_IROTH | S_IWOTH

3.2 关闭流

#include <stdio.h>

int fclose(FILE *fp);
  • 参数:
    • fp:待关闭的文件指针
  • 在该文件被关闭之前:
    • fclose会自动冲洗缓冲中的输出数据,缓冲区中的任何输入数据被丢弃
    • 若该缓冲区是标准I/O库自动分配的,则释放此缓冲区
  • 当一个进程正常终止时(直接调用exit函数,或者从main函数返回):
    • 所有带未写缓存数据的标准I/O流都被冲洗
    • 所有打开的标准I/O流都被关闭

3.3 读写流

三种不同类型的格式化I/O:

  1. 逐个字符读写,若流是带缓冲的,则标准I/O函数处理所有缓冲
  2. 逐行进行读写,fgets和fputs
  3. 直接I/O,fread和fwrite,每次I/O操作读或写某种数量的对象,这两个函数常用于从二进制中每次读写一个结构
1) 逐个字符读写流

输入函数

#include<stdio.h>

int getc(FILE *fp);//可定义为宏

int fgetc(FILE *fp);

int getchar(void);//标准输入
  • getchar等同于getc(stdin);
  • getc可以实现为宏,fgetc不能
  • 这三个函数返回下一个字符时,会将其unsigned char 类型转换为int 类型,为了兼容< stdio.h > 中的常量EOF文件尾端标示。

输出函数

#include<stdio.h>

int putc(int c,FILE *fp);//可定义为宏

int fputc(int c,FILE *fp);

int putchar(int c);//标准输出

这三者函数的特征及关系和输入函数一一对应

字符回流:我们可以将一个字符压入到流中。

#include<stdio.h>

int ungetc(int c,FILE *fp);
  • 不能回送EOF字符,但是当达到文件尾端时,依然可以回送字符,因为一次成功的ungetc调用会清楚文件结束标志
  • 常用于字符串切分操作,有时需要先看一下下一个字符,决定如何处理当前字符
  • ungetc并没有将字符写到底层文件和设备中,只是将它们写回标准I/O库的流缓冲区中。
2) 错误处理

当对流进行读写操作时,无论是出错还是到达文件尾端,1)中的三个输入函数都返回相同的值,为了区分这两种不同的情况,我们使用ferror或feof来区分:

int ferror(FILE *fp);
int eof(FILE *fp);

void clearerr(FILE *fp);

在大多数实现中,为每个流在FILE对象中维护了两个标志:

  • 出错标志
  • 文件结束标志

调用clearerr可以清除这两个标志。

3) 逐行读写数据

输入数据:

#include<stdio.h>

char *fgets(char *restrict buf,int n ,FILE *restrict fp);

char *gets(char *buf);//标准行输入

  • gets从标准输入读,而fgets则从指定的流
  • fgets必须指定缓冲区的长度,此函数一直读到下一个换行符为止,但是不超过n-1个字符。
    • 缓冲区必须以null字节结尾
    • 若读取的该行数据加上换行符超过n-1个字符,则fgets只返回一个不完整的行,下一次调用fget依然继续读取该行数据
  • gets是一个不推荐使用的函数
    • 不指定缓冲区长度可能造成缓冲区溢出
    • gets不将换行符存入到缓冲区

输出数据:

#include<stdio.h>

char *fputs(char *restrict buf,int n ,FILE *restrict fp);

char *puts(char *buf);//标准行输出

  • fputs将一个以null字节终止的字符串写到指定的流,尾端的终止符null不写出
  • puts将一个以null字节终止的字符串写到标准输出,终止符不写出,但是puts会自动添加一个换行符写到标准输出

我们应该尽可能使用fputs,fgets来处理行输入输出,自己处理换行符

getc,putc不是标准输入输出,只不过可以定义为宏,gets,puts是标准的输入输出,不要混淆

4) 二进制I/O

这是第三类输入输出的函数,直接I/O,以结构为单位进行输入输出。

#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 *restrict fp);

  • 参数:
    • ptr:存放二进制数据对象的缓冲区地址
    • size:单个二进制数据对象的字节数(比如一个struct的大小)
    • nobj:二进制数据对象的数量
    • fp:打开的文件对象指针
  • 返回值:
    • 成功或失败: 读/写的对象数
      • 对于读:如果出错或者到达文件尾端,则此数字可以少于nobj。此时应调用ferror或者feof来判断究竟是那种情况
      • 对于写:如果返回值少于nobj,则出错

使用二进制I/O的基本问题是:它只能用在读取同一个操作系统上已写的数据。如果跨操作系统读写,则很可能工作异常。因为:

  • 同一个struct,可能在不同操作系统或者不同编译系统中,成员的偏移量不同
  • 存储多字节整数和浮点数的二进制格式在不同的操作系统中可能不同

3.4 定位流

三种定位方式:

  1. ftell和fseek函数:假定文件长度可以用一个长整型long存放
  2. ftello和fseeko函数:使用off_t数据类型代替long
  3. fgetpos和fsetpos函数:使用抽象数据类型fpos_t记录文件的位置。这种数据类型可以根据需要定义一个足够大的树,记录文件位置

(1)

#include<stdio.h>

long ftell(FILE *fp);

int fseek(FILE *fp,long offset, int whence);

void rewind(FILE *fp);

  • 对于二进制文件
    • 其文件位置指示器是从文件起始位置开始度量,并以字节为度量单位
    • ftell用于二进制文件时,返回这种字节位置
    • fseek可以定位位置,whencelseek中的whence相同
  • 对于文本文件(中间涉及到ASCLL码等字符集编码转换)
    • 它们的文件位置可能不以简单的字节偏移量来度量。因为在非Unix系统中,它们可能以不同的格式存放文本文件
    • 为了定位文本文件,whence一定要是SEEK_SET,而且offset只能是0或是对该文件的ftell所返回的值

rewind函数也可以将一个流设置到文件的起始位置

(2)

#include<stdio.h>

off_t ftello(FILE *fp);

int fseeko(FILE *fp,off_t offset, int whence);
  • 除了将long替换为off_t,其它没有区别

(3)

#include<stdio.h>

long fgetpos(FILE *restrict fp,fpos_t *restrict pos);

int fsetpos(FILE * fp,const fpos_t * pos);

fgetpos将文件位置指示器的当前值存入由pos指向的对象中,在以后调用fsetpos时,可以使用此值将流重新定位至该位置

3.5 格式化I/O

1) 格式化输出

unix_ad_11.png

  • printf 将格式化数据写到标准输出
  • fprintf 写至指定的流
  • dprintf 写至指定的文件描述符。使用该函数不需要调用fdopen将文件描述符转换为文件指针(fprintf需要)
  • sprintf 将格式化的字符送入数组buf中,并自动在该数组尾端加一个null字节,但该字符不包括在返回值中
  • snprintf sprintf可能会造成buf指向的缓冲区溢出,snprintf的参数n指明了缓冲区长度,超过缓冲区尾端写的所有字符都被丢弃,因此可以解决缓冲区溢出问题

格式说明的格式%[flags][fldwidth][precision][lenmodifier]convtype

  • 标志(flags)

    unix_ad_12.png

  • 最小字段宽(fldwidth)
    • 说明最小字段宽度
    • 转换后参数字符如果小于宽度,则多余字符位置用空格填充
    • 字段宽度是一个非负十进制数,或者是一个星号 *
  • 精度(precision)
    • 说明整型转换后最少输出数字位数
    • 说明浮点数转换后小数点后的最少位数
    • 说明字符串转换后最大字节数
    • 精度是一个点.后跟随一个可选的非负十进制数或者一个星号*
  • 参数长度(lenmodifier)
    • unix_ad_13.png
  • 转换类型(convtype)
    • unix_ad_14.png

2) 格式化输入

unix_ad_15.png

scanf族用于分析输入字符串,将字符序列转换成指定类型的变量。在格式之后的各参数中包含了变量的地址,用转换结果对这些变量赋值

除了转换说明和空白字符以外,格式字符串中的其他字符必须与输入匹配。如有一个字符不匹配,则停止后续处理,不再读输入的其余部分

转换说明的格式%[*][fldwidth][m][lenmodifier]convtype

  • *:用于抑制转换。按照转换说明的其余部分对输入进行转换,但是转换结果不存放在参数中
  • fldwidth:说明最大宽度,即最大字符数
  • lenmodifier:说明要转换结果赋值的参数大小。见前述说明
  • convtype:类似于printf族的转换类型字段。但是稍有区别:输入中的带符号的数值可以赋给无符号类型的变量
  • m:赋值分配符。用于强迫内存分配。当%c,%s时,如果指定了m,则会自动分配内存来容纳转换的字符串。同时该内存的地址会赋给指针类型的变量(即要求对应的参数必须是指针的地址)。同时要求程序员负责释放该缓冲区(通过free函数)

3.6 获取流FILE对象的文件描述符

如果要调用dup或fcntl等函数,则需要此函数:

#include<stdio.h>

int fileno(FILE *fp);

3.7 临时文件

ISO C标准I/O库提供两个函数以帮助创建临时文件:

#include<stdio.h>

char *tmpnam(char *ptr);

FILE *tmpfile(void);

  • tmpnam产生一个与现有文件名不同的一个有效路径字符串:
    • 最多调用的次数为TMP_MAX
    • 若ptr是NULL,则产生的路径名存放在一个静态区,返回该指针
    • 若ptr不为NULL,则认为指向长度至少为L_tmpnam个字符的数组,将名称存放在该数组,然后返回ptr即可
  • tmpfile创建一个临时文件,并返回该文件相关的流FILE对象
    • 先调用tmpnam产生一个唯一的路径名
    • 然后用该路径名创建一个文件,然后立即unlink
    • 对一个文件解除链接并不删除其内容,关闭该文件时才删除其内容

UNIX Specification为处理临时文件定义了两个额外的函数:

#include<stdlib.h>

char *mkdtem(char *template);//返回指向目录名的指针

int mkstemp(char *template);//返回临时文件的文件描述符

  • mkdtemp 创建一个目录,该目录有一个唯一的名字
    • mkdtemp函数创建的目录具有权限位集: S_IRUSR|S_IWUSR|S_IXUSR。调用进程的文件模式创建屏蔽字可以进一步限制这些权限
    • mkstemp函数返回的文件描述符以读写方式打开。它创建的文件用访问权限位:S_IRUSR|S_IWUSR
  • mkstemp 创建一个文件,该文件有一个唯一的名字
    • tmpfile不同,mkstemp创建的临时文件并不会自动删除。如果希望从文件系统命名空间中删除该文件,必须自己对它解除链接
  • mkdtemp和mkstemp都是将修改template字符串反映临时文件的名字 :
    • 名字是通过template字符串进行选择的。这个字符串是后6位设置为XXXXXX的路径名。函数将这些占位符替换成不同的字符来构建一个唯一的路径名。如果成功的话,这两个函数将修改template字符串反映临时文件名
    • 这两个函数是原子操作,相对于tmpnam和tmpfile

4.内存流

内存流:一种标准IO流,虽然它通过 FILE指针来访问,但是并没有底层的文件 。所有的IO都是通过在缓冲区和主存之间来回传送字节来完成。虽然它看起来像是文件流,但是更适用于字符串操作

#include <stdio.h>

FILE *fmemopen(void *restrict buf,size_t size,const char *restrict type);
  • 参数:
    • buf:内存流缓冲区的起始地址
    • size:内存流缓冲区的大小(字节数)
      • bufNULL时,则函数负责自动分配size字节的缓冲区,并在流关闭时自动释放分配的缓冲区
    • type:控制如何使用流(即打开内存流的方式)

unix_ad_16.png

注意:

  • 无论何时以追a方式打开内存流时,当前文件位置设为缓冲区中第一个null字节处。
    • 若缓冲区中不存在null字节,则当前位置设为缓冲结尾的后一个字节
  • 当内存流不是a方式打开时,当前位置设置为缓冲区的开始位置
  • 如果bufnull,则打开流进行读或者写都没有任何意义。因为此时缓冲区是通过fmemopen分配的,没办法找到缓冲区的地址。
  • 任何时候需要增加流缓冲区中数据流以及调用 fclose、fflush、fseek、fseeko、fsetpos时都会在 当前位置写入一个null字节

创建内存流的其他两个函数:

#include<stdio.h>

FILE * open_memstream(char **bufp,size_t *sizep);

#include <wchar.h>

FILE *open_wmemstream(wcha_t **bufp,size_t *sizep);
  • 参数:
    • bufp:指向缓冲区地址的指针(用于返回缓冲区地址)
    • sizep:指向缓冲区大小的指针(用于返回缓冲区大小)

open_memstream 创建的流是面向字节的,open_wmemstream 创建的流是面向宽字节的

这两个函数与fmemopen的不同在于:

  • 创建的流只能写打开
  • 缓冲区由函数自动创建,不能指定自己的缓冲区
  • 关闭流后需要程序员释放缓冲区
  • 对流添加字节会增加缓冲区大小

在缓冲区地址和大小使用上要遵守规则:

  • 缓冲区地址和长度只有在调用fclose或者fflush后才有效
  • 这些值只有在下一次写入或者调用fclose之前才有效。因为缓冲区可能增长,也可能需要重新分配

因为避免了缓冲区溢出,内存流非常适用于创建字符串。因为内存流只访问主存,不访问磁盘上的文件,所以对于把标准I/O流作为参数用于临时文件的函数来说,会有很大的性能提升

四.进程环境

1.进程的启动与终止

1.1 main函数

int main(int argc,char *argv[]);
  • 参数:
    • argc:命令行参数的数目(ISO C和POSIX.1都要求argv[argc]是一个空指针)
    • argv:由指向各命令行参数的指针所组成的数组。ISOCPOSIX都要求argv[argc]是一个空指针

1.2 进程终止的方式

有 8 种方式使得进程终止,其中 5 种为正常终止,3 种异常终止:

  • 正常终止方式
    • main函数返回
    • 调用exit函数
    • 调用_exit函数或者_Exit函数
    • 多线程的程序中,最后一个线程从其启动例程返回
    • 多线程的程序中,从最后一个线程调用pthread_exit函数
  • 异常终止方式
    • 调用abort函数
    • 接收到一个信号
    • 多线程的程序中,最后一个线程对取消请求作出响应

1.3 终止函数

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

  • _exit和_Exit立即进入内核
  • exit则先执行一些清理工作,然后返回内核

#include<stdlib.h>

void exit(int status);//status为终止状态

void _Exit(int status);

#include<stdio.h>

void _exit(int status);
  • exit总是执行一个标准I/O库的清理关闭操作:对于所有打开流调用fclose函数
  • 三个退出函数都带一个整型参数,称为终止状态
    • 进程的终止状态在以下三个情况下是未定义的:
      • 调用该三个函数,没有带终止状态
      • main函数执行一个没有返回值的return 语句
      • main没有声明返回类型为整型
  • main函数返回一个整型值与用该值调用exit是等价的:
    • exit(10)等价于
    • return (10)

1.4 登记终止处理函数

#include <stdlib.h>

int atexit(void (*func)(void));
  • 参数:
    • func:函数指针。它指向的函数的原型是:返回值为void,参数为void

按照ISO C的规定,一个进程可以登记最多32个函数(通常操作系统会提供多于32个的限制。可以用sysconf函数查询这个限制值),这些函数将由exit函数自动调用。这些函数称作终止处理程序

exit首先调用各终止处理程序,然后关闭(通过fclose)所有打开流

  • exit调用这些终止处理程序的顺序与它们登记的时候顺序相反
  • 如果同一个终止处理程序被登记多次,则它也会被调用多次

2 环境表

  • 每个程序都接受一张环境表,与参数表一样,环境表也是一个字符指针数组:
    • 数组中的每个指针指向一个以null字节结尾的C字符串
    • 该数组也是以null结尾
  • 全局变量envrion包含了该指针数组的地址:extern char **envrion。我们称environ为环境指针,它位于头文件unistd.h
  • 按照惯例,环境字符串由name=value这种格式的字符串组成

unix_ad_17.png

环境变量一般存放在进程存储空间的顶部(栈的上方)

2.1 获取环境变量

环境字符串的形式为:

name = value

函数:

#include<stdlib.h>

char *getenv(const char * name);//返回值:指向与name相关联的value的指针,若未找到,返回NULL

下图为一些环境变量和在不同系统上的支持情况:

unix_ad_18.png

2.2 设置环境变量

并不是所有的系统都支持设置环境变量:

unix_ad_19.png

这三个函数:

unix_ad_20.png

  • putenv:新添环境变量,取形式为name = value的字符串,将其放到环境表中,如果name存在,则先删除原来的定义
  • setenv:设置name的value,如果name 已经存在,则根据参数rewrite(是否覆盖写)有如下两种情况:
    • 若 rewrite为0,则不删除其现有定义(name不设置为新的value,而且也不出错)
    • 若 rewrite非0,则首先删除现有定义,然后再设置
  • unsetenv删除name的定义。即使不存在这种定义也不算出错。

  • 这些函数在修改环境变量的实现过程非常复杂
    • 主要是因为,环境表盒环境字符串通常占用的是进程地址空间的顶部,所以它不能在向高地址*扩展,同时也不能向低地址扩展,因为不能移动在它下面的各栈帧
  • 修改一个现有的name:
    • 若新value长度 <= 旧value ,直接复制
    • 若新value长度 > 旧value,在堆中malloc分配新的字符串空间,将环境表中的指针指向它
  • 新添一个新的name:
    • 第一次添加,需要在堆中为环境表malloc(需要扩展一个位置)数组空间,然后复制过去,新添加的name=value字符串也在堆中malloc空间存储。
    • 第二次添加,说明已经在堆中malloc了,所以直接调用*realloc即可
  • 删除
    • 则只需要先在环境表中找到该指针,然后将所有的后续指针都向环境表的首部依次顺序移动一个位置即可

3.C程序的存储空间分布

unix_ad_17.png

  • 正文段: CPU执行的机器指令部分,所有程序代码,函数代码的存放区域,正文段可共享,且只读
  • 初始化数据段:此段通常称为数据段,包含程序中需明确地赋初值的变量,例如任何C程序中任何函数之外的声明:
      int maxcount = 99;
    
  • 未初始化数据段:此段通常称为bss段(block start by symbol:由符号开始的块)在程序开始执行之前,内核将此段中的数据初始化为0.
  • :自动变量以及每次函数调用时所需要保存的信息都存放在此段。每次函数调用时,其返回地址及调用者环境信息都存放在栈中。
  • :通常在堆中进行动态存储分配

注意:

  1. 栈从高地址向低地址增长。堆顶和栈顶之间未使用的虚拟地址空间很大
  2. 未初始化数据段的内容并不存放在磁盘程序文件中。需要存放在磁盘文件中的段只有正文段初始化数据段size a.out令可以查看程序的正文段、数据段 和bss段长度)

4.共享库

  • 共享库使得可执行文件不在需要 包含(include)公有的库函数,只需要在所有进程都可以引用的存储区保存这种 库例程的一个副本
  • 程序第一次执行或第一次调用某个库函数的时候,用动态链接方法将程序与共享库函数相连接。
  • 这减少了可执行文件的长度,但增加了一些运行时间的开销,这种时间的开销发生在该程序第一次被执行时,或该共享库函数第一次被调用时(因为是动态链接
  • 还有一个优点:库函数版本升级时,无需对使用该库的程序重新连接编译

示例显示使用共享库的效果,数据长度大大减少:

unix_ad_21.png

5.存储空间分配

unix_ad_22.png

  • malloc :分配指定字节数的存储区。存储区中的初始值不确定
  • calloc :为指定数量指定长度的对象分配存储空间。空间中的每一位都初始化为0
  • realloc :增加或减少以前分配区的长度
    • 当增加长度时,可能需将以前分配区的内容移到另外一个足够大的区域,以便在尾端提供增加的存储区,而新增区域内的初始值则不确定
    • 函数调用前后可用是不同的存储区,所有调用前不应该有指针指向这段存储区,不然修改后,可能该指针会非法访问
    • 应该使用另一个指针保存realloc的返回值,因为如果使用传入的实参保存返回值,那么一旦realloc失败,则会传回NULL,原来的动态内存区再也无法访问,从而发生内存泄露
  • free :上述3个函数都需通过free释放,被释放的空间通常被送入可用存储区池,以便以后利用

这3个分配函数所返回的指针一定是适当对齐的,使其可用于任何数据对象

这些分配例程通常用sbrk(2)系统调用实现

  • sbrk可以扩充或缩小进程的存储空间。但是大多数malloc和free的实现都不减小进程的存储空间:释放的空间可供以后再分配,但将它们保持在malloc池中而不返回给内核
  • 大多数实现所分配的存储空间要比所要求的稍大一些,额外的空间用来记录管理信息,比如分配块的长度、指向下一个分配块的指针等等(如果在一个动态分配区的尾部之后或者在起始位置之前写操作会修改另一块的管理记录信息。这种类型的错误是灾难性的,但是由于这种错误不会立即暴露出来,因此很难被发现)
  • mallocfree相关的致命性错误
    • 调用了malloc函数但是没有调用free函数:会发生内存泄漏,该进程占用的存储空间就会连续增加,直到不再有空闲空间。此时过度的换页开销会导致性能下降
    • free一个已经释放了的块
    • 调用free时所用的指针不是3个alloc函数的返回值

6.进程的资源限制

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

#include<sys/resource.h>

int getrlimit(int resource,struct rlimit *rlptr);//将获取的资源限制信息存放在rlptr结构中

int setrlimit(int resource,const struct rlimit *rlptr);//将rlptr指向的资源限制信息设置为resource资源的限制

//上面两个函数,成功返回0,不成功,返回非0


struct rlimit{
    rlim_t rlim_cur;
    rlim_t rlim_max;
}

更改资源限制需要遵循以下三条规则:

  • 软资源限制<=硬资源限制
  • 任何进程都可以降低硬资源限制,不可逆操作
  • 只有超级用户进程可以提高硬资源限制

  • 参数
    • resource:相应的资源。可以设置为下列值(不同系统支持不同):
      • RLIMIT_AS:进程总的可用存储空间的最大长度(字节)(这会影响到sbrk函数和mmap函数)
      • RLIMIT_COREcore文件的最大字节数。如果为0,则阻止创建core文件
      • RLIMIT_CPU:CPU时间的最大量值(秒),如果超过此软限制时,向该进程发送SIGXCPU信号
      • RLIMIT_DATA数据段的最大字节长度(包括初始化数据、非初始以及堆的总和)
      • RLIMIT_FSIZE:可以创建的文件的最大字节长度。当超过此软限制时,向该进程发送SIGXFSX信号
      • RLIMIT_MEMLOCK:一个进程使用mlock能够锁定在存储空间中的最大字节长度
      • RLIMIT_MSGQUEUE:进程为POSIX消息队列可分配的最大存储字节数
      • RLIMIT_NICE:为了影响进程的调度优先级nice值可设置的最大限制
      • RLIMIT_NOFILE:每个进程能打开的最多文件数。更改此限制将影响到sysconf函数在参数_SC_OPEN_MAX中返回的值
      • RLIMIT_NPROC:每个实际用户ID可以拥有的最大子进程数。更改此限制将影响到sysconf函数在参数_SC_CHILD_MAX中返回的值
      • RLIMIT_RSS:最大驻内存集字节长度
      • RLIMIT_SIGPENDING:一个进程可排队的信号的最大数量。这个限制是sigqueue函数实施的
      • RLIMIT_STACK栈的最大字节长度

资源限制影响到调用进程并由其子进程继承

五.进程控制

1.进程标识

  • 系统有一些专属进程
    • ID为0的进程通常为调度进程,也被称作交换进程,该进程是内核的一部分,并不执行任何磁盘上的程序,也被称作系统进程
    • ID为1的进程通常是初始化进程,由内核调用,该进程的程序文件存放在/etc/init或/sbin/init中,init进程绝不会终止,它是一个普通的用户进程,但以超级用户特权运行
    • 某些UNIX虚拟存储器实现中,进程ID2是页守护进程,此进程负责支持虚拟存储器系统的分页操作
  • 一些和进程ID相关的函数
    • unix_ad_23.png
    • 这些函数没有出错返回

2.进程的创建

fork vfork

2.1 fork

#include<unistd.h>

pid_t fork(void);

  • fork调用一次但返回两次,返回子进程0,返回父进程子进程的ID
  • 子进程和父进程继续执行fork调用后的指令,子进程是父进程的副本
    • 例如,子进程获得父进程数据空间,堆和栈的副本
    • 该副本并不是父进程和子进程共享的部分,父进程和子进程只共享正文段
  • 现在很多实现并不执行一个父进程的数据段、堆和栈的完全副本,作为替代,使用了写时复制技术
  • fork之后是父进程先执行还是子进程先执行并不确定,取决于内核所使用的调度算法

文件共享

在重定向父进程的标准输出时,子进程的标准输出也被重定向。

  • fork的一个特性是父进程的所有打开文件描述符都被复制到子进程,但父进程和子进程每个相同的打开的描述符**共享一个文件表项(文件对象) **
  • 所以父进程和子进程共享一个文件偏移量
  • fork之后处理文件描述符有以下两种常见的情况
    • 父进程等待子进程完成
    • 父进程和子进程各自执行不同程序段,各自关闭它们不使用的文件描述符,网络服务器经常这么使用(父进程关闭客户端连接套接字,子进程关闭监听套接字unix_ad_24.png

除了打开文件之外,父进程还有很多其它的属性也由子进程继承:

  • 实际用户ID
  • 实际组ID
  • 有效用户ID
  • 有效组ID
  • 附属组ID
  • 进程组ID
  • 会话ID
  • 控制终端
  • 设置用户ID标志和设置组ID标志
  • 当前工作目录
  • 根目录
  • 文件模式创建屏蔽字
  • 信号屏蔽和信号处理
  • 对任一打开文件描述符的执行时关闭标志
  • 环境
  • 连接的共享存储段
  • 存储映像
  • 资源限制

父进程和子进程的区别主要如下:

  • fork返回值不同
  • 进程ID不同
  • 进程父进程ID不同
  • 子进程的tms_utime,tms_stime,tms_cutime,tms_ustime的值设置为0
  • 子进程不继承父进程设置的文件锁
  • 子进程的未处理闹钟被清除
  • 子进程的未处理信号集设置为空集

fork失败的两个主要原因为:

  1. 系统中已经有了太多的进程(全局的限制)
  2. 该实际用户ID的进程总数超过了系统限制(单个用户的限制)

fork的两大用法:

  1. 一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段,这在网络服务器中很常见
  2. 一个进程要执行一个不同的程序,子进程fork返回后立即调用exec

2.2 vfork

  • vfork函数的调用顺序和返回值与fork相同,都是创建一个新进程
    • 但vfork的目的更明确,就是为了 exec一个新的程序(替换当前所有进程映像,开始从main执行程序)。
    • 不复制:vfork并不将父进程的地址空间完全复制的子进程
    • vfork在调用exec或exit之前,在父进程的空间运行
    • vfork保证子进程先运行,在它调用exec或exit后父进程才有可能被调度运行,fork不确定。
    • 死锁的可能:若vfork的子进程在调用exec或exit之前依赖于父进程的进一步动作,此时父进程等待子进程运行结束。
int globval =6;

int main(int argc,char ** argv) {
    int val;
    pid_t pid;
    val = 88;
    printf("before vfork\n");
    if ((pid = vfork()) < 0) {
        exit(1);
    } else if (pid == 0) {
        globval++;
        val++;
        _exit(0); //_exit并不执行标准I/O缓冲区的冲洗操作,如果调用的是exit,输出是不确定的
    }
    printf("pid=%ld,glob=%d,val=%d\n", (long) getpid(), globval, val);
    exit(0);
}

上述程序子进程会修改父进程地址空间中的数据,子进程修改变量后即退出,以下为父进程的输出:

before vfork
pid=30595,glob=7,val=89
  • 若子进程调用exit:exit并不执行标准I/O缓冲区的冲洗操作,如果调用的是exit,输出是不确定的
    • 若exit只执行缓冲区冲洗操作,则没有区别
    • 若exit冲洗后关闭所有流连接,则没有输出,printf会返回-1

3.进程的终止

进程有五种正常终止和3中异常终止方式:

  • 五种正常终止方式
    • main内return,等效调用exit
    • 调用exit函数
      • exit函数=终止处理程序+I/O关闭处理程序+_exit或_Exit(将退出状态作为参数转换为终止状态)
    • 调用_exit或_Exit,直接终止程序,不运行终止处理程序和信号处理程序,及I/O冲洗(取决于具体实现),并 将退出状态转换为终止状态
    • 最后一个线程return
    • 最后一个线程pthread_exit
  • 三种异常终止方式
    • 调用abort(),产生SIGABRT信号终止
    • 进程接受到某些信号时终止
    • 最后一个线程对“取消做出响应”,通常一个线程要求取消另一个线程。

不管进程最后如何终止,最后都会执行内核中同一段代码,这段代码为相应进程关闭所有打开的描述符,释放它所使用的存储器

  • 退出状态:正常退出时的状态。
  • 终止状态:正常退出时退出状态可以转换为终止状态,异常退出时则由内核产生一个指示其异常终止原因的终止状态

3.1 父子进程以不同的顺序终止

  • 父进程先于子进程退出:产生孤儿进程,所有子进程的父进程都改为init进程
    • 在一个进程终止时,内核逐个检查所有活动进程,判断它是否是正要终止进程的子进程,若是,则该进程的父进程ID改为1.
  • 子进程先于父进程退出:可能产生僵死进程,当子进程退出时,内核为每个终止进程保存一定量的信息,当父进程通过调用wait和waitpid时可以得到这些信息。
    • 这些信息包括进程ID,进程终止状态及进程使用的CPU时间总量
    • 若父进程没有对子进程进行善后处理,该终止进程的信息仍被内核所保留,这类进程被称为僵死进程

一个init进程收养的进程永远不会变为僵死进程,因为只要init进程有任何一个子进程终止,init都会调用一个wait函数取得终止状态。

3.2 父进程对子进程的善后处理

当子进程正常终止或异常终止时,内核会向其父进程发送SIGCHLD信号,父进程则需要捕捉该信号然后调用wait或waitpid函数进行善后处理

#include <sys/wait.h>

pid_t wait(int statloc);

pid_t waitpid(pid_t pid,int* statloc,int options);
  • 参数
    • statloc:如果关心终止状态就传入一个int变量的地址,终止状态将存于其中;不关心终止状态可以设为NULL。该整形状态字是由实现定义的:
      • 某些位表示退出状态(正常返回)
      • 某些位指示信号编号(异常返回)
      • 有一位指示是否产生了core文件 可以使用POSIX.1规定,终止状态用定义在<sys/wait.h>中的宏来查看,下图4个互斥的宏可用来取得进程终止的原因。基于这4个宏中哪一个值为真,就可选用其它宏来取得退出状态,信号编号等: unix_ad_25.png

wait函数不能阻塞等待指定的pid进程终止,但waitpid可以实现不阻塞等待指定pid进程终止,实现方式通过参数pid和option来实现:

  • 参数pid:
    • -1:等待任一子进程,这种情况下与wait等效
    • >0:等待进程ID与pid相等的子进程
    • 0:等待组ID等于调用进程组ID的任一子进程
    • <-1:等待组ID等于pid绝对值的任一子进程
  • 参数option:可以为0,也可以是下面三个常量按位或运算的结果
    • unix_ad_26.png

waitid:Single UNIX Specification包括另一个取得进程终止状态的函数:waitid,类似于waitpid,但更灵活

#include<sys/wait.h>

int waitid(idtype,id_t id,siginfo_t *infop,int options);

不同于waitpid,该函数通过两个参数idtype和id来取代waitpid中pid的作用,options也更加丰富:

unix_ad_27.png

options:

unix_ad_28.png

wait3和wait4

wait3不能等待指定pid进程终止,wait4可以,这两个函数的特点在于允许内核返回终止进程及其所有子进程使用的资源信息概况。

unix_ad_29.png

这些信息包括:用户CPU时间总量,系统CPU时间总量,缺页次数,接收到的信号次数

4.竞争条件

当多个进程要对共享数据进行某种处理,最后的结果又与进程运行的顺序相关,则认为发生了竞争条件。

对于父子进程直接可能存在这种进程条件,父子进程之间的数据处理顺序有一定依赖性,但我们并不能预料到是哪一个进程先执行,因为这取决于系统负载和内核的调度函数。

  • 如果父进程要等待子进程先执行完成,则需要调用wait等函数
  • 如果子进程要等待父进程先执行完成则可以使用下面形式的循环(轮询):
      while(getppid()!=1)//当父进程结束时,子进程的父进程变为init进程
          sleep(1);
    
  • 但使用轮询机制非常浪费CPU时间,为了避免这种竞争条件和轮询,我们可以使用
    • 信号机制
    • 进程间通信(IPC)

示例:父进程可能要用子进程ID更新日志文件中的一个记录,子进程可能要为父进程创建一个文件,每个进程在执行完它的一套初始化操作后要通知对方,并且在继续运行前,要等待其初始化操作。

TELL_WAIT();    /* set things up for TELL_xxx & WAIT_xxx */
if ( (pid = fork()) < 0 ){
    err_sys("fork error");
} else if(pid == 0){        /* child */
    /* child does whatever is necessary ... */
    TELL_PARENT(getppid());    /* tell parent we're done */
    WAIT_PARENT();             /* and wait for parent */
    /* and the child continues on its way ... */
    exit(0);
}
/* parent does whatever is necessary ... */
TELL_CHILD(pid);    /* tell child we're done */
WAIT_CHILD();       /* and wait for child */
/* and the parent continues on its way ... */
exit(0);

若要父进程先运行:

...
if((pid == fork())<0){
    err_sys("fork error");
}else if(pid == 0){
    WAIT_PARENT();//等待父进程先执行
    ...
}else{
    ...
    TELL_CHILD(pid);//父进程执行完后,告诉子进程
}
exit(0);
...

若要子进程先运行:

...
if((pid == fork())<0){
    err_sys("fork error");
}else if(pid == 0){
    ...
    TELL_PARENT(getppid());//子进程执行完告诉父进程
}else{
    WAIT_CHILD();//等待子进程先执行
    ...
    
}
exit(0);
...

5.函数exec

  • 当进程调用exec函数时,该进程执行的程序完全替换为新程序:
    • 新程序从main函数开始执行,因为调用exec并不创建新进程,所以进程ID不变
    • 只有该进程的进程映像(包括:正文段,数据段,堆段,栈段)被替换

有七个不同exec函数可以被使用:

unix_ad_30.png

  • 新程序指定方式
    • 前四个函数取路径名作为参数
    • 后两个函数取文件名作为参数(函数名中带p
      • 如果filename中包含/,则将其视为路径名
      • 否则,按PATH环境变量,在它所指定的各目录中搜寻可执行文件
    • 最后一个文件描述符做参数
  • 新程序的命令行参数
    • 函数名中的l:表示列表。要求将新程序的每个命令行参数都说明为一个单独的参数,这种参数表以空指针结尾
    • 函数名中的v:表示矢量。先构造一个指向各参数的指针数组,然后将该指针数组的地址作为参数
  • 新程序的环境变量
    • 函数名中的e:可以传递一个指向环境字符串指针数组的指针,数组最后一个元素必须是空指针
    • 否则:使用进程的environ变量为新程序复制现有环境

七个函数关系如下图(只有execve为系统调用):

unix_ad_31.png

执行exec后,新进程从调用进程中继承如下属性:

  • 进程ID和父进程ID
  • 实际用户ID和实际组ID
  • 附属组ID
  • 进程组ID
  • 会话ID
  • 控制终端
  • 闹钟尚余留的时间
  • 当前工作目录
  • 根目录
  • 文件模式创建屏蔽字
  • 文件锁
  • 进程信号屏蔽
  • 未处理信号
  • 资源限制
  • nice值
  • tms_utime、tms_stime、tms_cutime以及tms_cstime值

注意:

  • 进程中每个打开的文件描述符都有一个执行时关闭标志。若设置了此标志,则执行exec时会关闭该文件描述符;否则该文件描述符仍然保持打开。系统默认行为是不设置执行时关闭标志
  • 进程的 实际用户ID实际组ID 不变,有效用户ID有效组ID 是否改变取决于所执行程序文件的设置用户ID位设置组ID位是否设置
    • 1)若新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID;2)否则有效用户ID不变
    • 2)若新程序的设置组ID位已设置,则有效组ID变成程序文件所有组的ID;2)否则有效组ID不变
    • 有效ID:对该执行文件有访问权限的用户ID,及真正执行该文件的用户ID
    • 用户ID:实际拥有该文件的用户ID

6.更改用户ID和更改组ID

该部分的功能主要是为了动态更改进程的“特权”和“访问控制”

UNIX中一个进程的权限是基于用户ID和组ID实现的。

  • 当程序需要增加特权时,或需要访问当前不允许访问的资源时,我们需要更改用户ID和组ID增加特权
  • 当程序需要降低其特权或阻止对某些资源的访问时,也可以通过该方式实现

setuid用于设置实际用户ID和有效用户ID:

#include<unistd.h>

int setuid(uid_t uid);

int setgid(gid_t gid);

更改用户ID和组ID的规则:

  1. 超级用户特权的进程才能通过setuid将实际用户ID,有效用户ID以及保存的设置用户ID设置为uid
  2. 若进程没有超级用户特权,但uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid.不能更改实际用户ID和保存的设置用户ID。
  3. 若以上两个条件都不满足,则errno设置为EPERM并返回-1

还有需要注意的三点:

  1. 只有超级特权用户才能更改实际用户ID,该ID将用户登录时login(1)程序设置的。login(1)程序是一个超级用户进程,可以设置三个用户ID
  2. 仅当对程序文件设置了 “设置用户ID位”时,exec函数才设置有效用户ID.否则,exec不会更改有效用户ID。任何时候都可以调用setuid来将有效用户ID设置为实际用户ID和保存的设置用户ID,不能将有效ID设置为任意随机值
  3. 保存的设置用户ID:exec从原进程中复制过来的有效ID。

unix_ad_32.png

getuid和geteuid分别用于获取实际用户ID和有效用户ID

6.1交换实际用户ID和有效用户ID

#include<unistd.h>

int setreuid(uid_t ruid, uid_t euid);

int setregid(gid_t rgid, gid_t egid);
  • 若其中任意一个参数为-1,则表示相应的ID应保持不变
  • 一个非特权用户可以交换实际ID和有效ID,这就允许一个设置用户ID程序交换成用户的普通权限,然后又可以交换回来。

6.2设置有效ID

前面介绍的函数setuid可以根据进程不同的权限设置不同的ID,这一节介绍的函数只能设置有效用户ID和有效组ID

#include<unistd.h>

int seteuid(uid_t uid);

int setegid(gid_t gid);
  • 非特权用户:只能将有效ID设置为实际ID或保存的设置用户ID
  • 特权用户:可以将有效ID设置为任何值uid

unix_ad_33.png

7.system函数

system函数将一个字符串作为命令来执行:

#include<stdlib.h>

int system(const char *cmdstring);
  • 参数:
    • cmdstring:命令字符串(在shell中执行),如 "ps -aux"(如果cmdstring为空指针,则如果system返回 0 表示该操作系统不支持system函数;否则支持)
  • 返回值:
    • system等同于同时调用了fork、exec、waitpid,有3种返回值:
      • fork失败或者waitpid返回除EINTR之外的错误,则system返回 -1,并且设置errno以指示错误类型
      • 如果exec失败(表示不能执行shell),则其返回值如同shell执行了exit(127)一样
      • 如果三个函数都执行成功,则system返回值是shell的终止状态,其格式在waitpid中说明

system VS fork + exec

  • 优点system进行了所需的各种出错处理以及各种信号处理
  • 缺点:一旦调用system的进程具有超级用户权限,则system执行的命令也具有超级用户权限。因为system的实现过程中并没有更改有效用户ID实际用户ID的操作
    • 因此如果一个进程以特殊的权限运行,而它又想生成另一个进程执行另外一个程序,则它应该直接使用fork + exec并且在fork之后,exec之前改回普通权限
    • 设置用户ID和设置组ID程序绝不应该调用system函数(因为exec会将有效ID设置为文件的用户ID)

8.进程调度

进程可以通过调整nice值选择以更低优先级运行,只有特权进程允许提高调度权限

  • nice值的范围在0~(2*NZERO)-1之间,有些实现支持0~2*NZERONZERO是系统默认的nice值)
  • nice值越低,优先级越高

进程可以通过nice函数获取或更改它的nice值,进程只能影响自己的nice值,不能影响任何其他进程的nice值:

#include<unistd.h>

int nice(int incr);
  • 参数
    • incrnice值的增量。如果太小或太大,系统会修改到边界值(为0时,nice值不变,因此可以用以获取当前nice值)
  • 返回值
    • -1:出错会返回-1,但是正常返回也可能是-1,所以调用前要清除errno

getpriority函数可以像nice函数那样用于获取进程的nice值,但是它还可以获取一组相关进程的nice值:

#include<sys/resource.h>

int getpriority(int which, id_t who);
  • 参数:
    • which:控制who参数是如何解释的。可以取三个值之一:
      • PRIO_PROCESS:表示进程
      • PRIO_PGRP:表示进程组
      • PRIO_USER表示用户ID
    • who:选择感兴趣的一个或者多个进程
      • 如果who为0,whichPRIO_PROCESS,返回当前进程的nice
      • 如果who为0,whichPRIO_PGRP,则返回进程组中最小的nice
      • 如果who为0,whichPRIO_USER,则返回调用进程的实际用户ID拥有的那些进程中最小的nice
    • valuenice的增量

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

#include<sys/resource.h>

int setpriority(int which,id_t who,int value);
  • 参数:
    • whichwho与getpriority函数中相同
    • value:nice值的增量

Single UNIX Specification没有对在fork之后子进程是否继承nice值制定规则。而是留给具体实现自行决定。但是遵循XSI的系统要求进程调用exec后保留nice值。在FreeBSD 8.0、Linux 3.2.0、MacOS x 10.6.8以及Solaris 10中,子进程从父进程中继承nice

9.进程时间

任一进程可调用times函数获得它自己以及已终止子进程的墙上时钟时间用户CPU时间系统CPU时间

#include<sys/times.h>

clock_t times(struct tms *buf)

该函数填写由buf指向的tms结构:

struct tms{
    clock_t tms_utime;  /* 用户CPU时间 */
    clock_t tms_stime;  /* 系统CPU时间 */
    clock_t tms_cutime; /* 已终止子进程的用户CPU时间 */
    clock_t tms_cstime; /* 已终止子进程的系统CPU时间 */
};

结构中两个针对子进程的字段包含了此进程用wait函数族已等待到的各子进程的值

墙上时钟时间(类似于计时器?)作为函数返回值返回。这个值是相对于过去的某一时刻度量的,所以不能用其绝对值而必须使用相对值。例如,调用times,保存其返回值。在以后某个时间再次调用times,从新返回的值中减去以前返回的值,此差值就是墙上时钟时间(一个长期运行的进程可能其墙上时钟时间会溢出,当然这种可能性极小)

所有由此函数返回的clock_t值都用sysconf(_SC_CLK_TCK)(每秒时钟滴答数)转换成秒数

八.线程

1.相关函数

1)pthread_create函数

该函数用于创建一个POSIX线程。当一个程序由exec启动执行时,称为“初始线程”或“主线程”的单个线程就创建了。其余线程则由pthread_create函数创建

unix_ad_34.png

  • tid:线程ID,数据类型为pthread_t,往往是unsigned int,如果线程成功创建,其ID就通过tid指针返回
  • attr:线程属性,包括:优先级、初始栈大小、是否应该成为一个守护线程等。设置为空指针时表示采用默认设置
  • func:该线程执行的函数
  • arg:该线程执行函数的参数,参数为一个无类型指针,如果需要向函数传递的参数有一个以上,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为参数传入

如果发生错误,函数返回指示错误的某个正值,不会设置errno变量

创建的线程通过调用指定的函数开始执行,然后显示地(通过调用pthread_exit)或隐式地(通过让该函数返回)终止

线程创建时,并不能保证哪个线程会先运行

2)pthread_join函数

pthread_join类似于进程中的waitpid,用于等待一个给定线程的终止

unix_ad_35.png

  • tid:等待终止的线程ID。和进程不同的是,无法等待任意线程,所以不能通过指定ID参数为-1来企图等待任意线程终止
  • status:如果该指针非空,来自所等待线程的返回值(一个指向某个对象的指针)将存入由status指向的位置

3)pthread_self函数

线程可以使用pthread_self获取自身的线程ID,类似于进程中的getpid

unix_ad_36.png

新线程不应该根据主线程调用pthread_create函数时传入的tid参数来获取自身ID,而是应该调用pthread_self,因为新线程可能在主线程调用pthread_create返回之前运行,如果读取tid,看到的是未经初始化的内容

4)pthread_detach函数

unix_ad_37.png

该函数把指定的线程转变为脱离状态,通常由想让自己脱离的线程调用:pthread_detach(pthread_self());

一个线程或者是可汇合的,或者是脱离的:

  • 可汇合:一个可汇合线程终止时,它的线程ID和退出状态将保存到另一个线程对它调用pthread_join。如果一个线程需要知道另一个线程什么时候终止,那就最好保持第二个线程的可汇合状态
  • 脱离:脱离的线程像守护进程,当它们终止时,所有相关资源都被释放,不能等待它们终止

5)pthread_exit函数

线程终止的一个方法

unix_ad_38.png

  • status:不能指向一个局部于调用线程的对象,因为线程终止时这样的对象也消失

让一个线程终止的另外两个方法:

  1. 线程执行的函数返回,在pthread_create参数中,这个函数的返回值是一个void*指针,它指向相应线程的终止状态
  2. 被同一进程的其它线程调用pthread_cancel取消(该函数只是发起一个请求,目标线程可以选择忽略取消或控制如何被取消)
  3. 如果进程的main函数返回或任何线程调用了exit_Exit_exit,整个进程就终止了,其中包括它的任何线程

6)pthread_equal函数

#include<pthread.h>

int pthread_equal(pthread_t tid1.pthread_t tid2);//相等返回非零,否则返回0 

线程ID是用pthread_t数据类型来表示的,实现的时候可以用一个结构来表示该数据类型,所以可移植的操作系统实现不能把它作为整数处理。因此必须使用一个函数来对两个线程ID进程比较

Linux 3.2.0使用无符号长整型表示pthread_t数据类型。Solaris 10将其表示为无符号整形。FreeBSD 8.0和Mac OS X 10.6.8用一个指向pthread结构的指针来表示pthread_t数据类型

7)pthread_cancel函数


#include<pthread.h>

int pthread_cancel(pthread_t tid);

该函数可以被某一线程调用,用来请求取消同一进程中的其它线程

  • 函数只是发起取消请求,目标线程可以忽略取消请求或控制如何被取消(即执行一些清理函数)

以下函数被线程调用时,可以添加或执行清理函数:

unix_ad_39.png

pthread_cleanup_push可以为线程添加清理函数,下列情况会调用清理函数(类似于进程中的atexit函数):

  • 线程调用pthread_exit
  • 线程响应取消请求时
  • 用非零execute参数调用pthread_cleanup_pop

以下情况不会调用清理函数;

  • 线程通过return终止时
  • execute参数为0时

不管excute参数是否为0,pthread_cleanup_pop函数都会将线程清理函数栈的栈顶函数删除

对比进程和线程的函数:

unix_ad_40.png

2.线程同步

2.1 互斥量

Unix网络编程套接字API中有部分描述

1) 互斥锁的初始化和销毁

  • 如果某个互斥锁变量是静态分配的,必须把它初始化为常值PTHREAD_MUTEX_INITIALIZER
  • 如果在共享内存区中分配一个互斥锁,必须通过调用pthread_mutex_init函数在运行时初始化,如果动态分配互斥量(如使用malloc函数)在释放内存前需要调用pthread_mutex_destroy

unix_ad_41.png

  • attr:互斥锁的属性。为NULL时表示使用默认的属性来初始化

如果释放互斥锁时,有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥锁加锁,其它线程就会看到互斥锁依然是锁着的,只能回去再次等待它重新变为可用。这种方式下,每次只有一个线程可以向前执行

2) 互斥锁的加锁解锁

unix_ad_42.png

  • mptr
    • pthread_mutex_lock锁住mptr指向的互斥锁,如果已经上锁,调用线程将阻塞
    • pthread_mutex_trylock和前者类似,如果已经上锁,不会阻塞,直接返回EBUSY
    • pthread_mutex_unlock将mptr指向的互斥锁解锁
  • 互斥锁并不会带来太大的开销

使用互斥锁时避免死锁

  • 如果线程试图对同一个互斥锁加锁两次,那么它自身就会陷入死锁状态
  • 如果使用多个互斥锁,并且每个线程取得其中一个,阻塞于对另一个的请求上,也会死锁
    • 1)可以通过控制加锁的顺序来防止
    • 2)如果尝试获取另一个锁时失败,那么释放自己占有的锁,过一段时间再试

3) 互斥锁的定时加锁

unix_ad_43.png

  • 设置超时:该函数试图对一个互斥锁进行加锁,如果互斥锁已经上锁,那么线程会阻塞到参数tsptr指定的时刻(这个时间是一个绝对时间,即某个时刻,而不是一个等待时间段)。如果仍为获得互斥锁,那么返回ETIMEDOUT

2.2 读写锁

也称作共享互斥锁:当读写锁是读模式锁住时,可以说成是共享模式锁住的;当它是写模式锁住时,可以说成是以互斥模式锁住的

读写锁与互斥锁类似,不过读写锁允许更高的并行性

  • 互斥锁要么是锁住状态,要么是不加锁状态,而且一次只有一个线程可以对其加锁
  • 读写锁可以有3种状态
    • 读模式下加锁状态(共享模式锁)
      • 可以添加读锁
      • 添加写锁的线程都会阻塞
      • 未防止读锁一直占用,添加写锁之后 禁止添加新的读锁
    • 写模式下加锁状态(互斥模式锁):一次只有一个线程可以占有写模式的读写锁
    • 不加锁状态
  • 读写锁非常适合对数据结构的读的次数远大于写的次数。

1) 读写锁的初始化和销毁

与互斥量相比,读写锁在使用前必须初始化,在释放它们底层的内存之前必须销毁

unix_ad_44.png

  • attr:锁的初始化属性,设为NULL时使用默认属性

Single UNIX Specification在XSI扩张中定义了PTHREAD_RWLOCK_INITIALIZER常量。如果默认属性就足够的话,可以用它对静态分配的读写锁进行初始化

2) 读写锁的加锁和解锁

根据读写锁的三个状态,加锁分为加锁读和加锁写两个不同的操作,解锁都是相同的:

unix_ad_45.png

Single UNIX Specification还提供了下列版本(用于非阻塞加锁):

unix_ad_46.png

3) 读写锁的定时加锁

同互斥锁一样,带有超时的读写锁加锁:

unix_ad_47.png

2.3 条件变量

条件变量提供信号机制,防止轮询访问,浪费cpu时间,UNIX网络编程API中有部分描述

1) 条件变量的初始化与销毁

同上:

unix_ad_48.png

  • attr:条件变量的初始化属性,设为NULL时使用默认属性

如果条件变量是静态分配的,那么可以使用PTHREAD_COND_INITIALIZER初始化

2) 等待某个条件变量

类似于加锁,当我们想要修改一个变量时:

问题:为什么pthread_cond_wait阻塞监听条件变量需要一个互斥量?

答案:因为往往一个条件变量就是等待一个共享变量发生变化后触发,所以我们必须保证在测试该共享变量(一般测试该共享变量是否为0)和阻塞监听该共享变量对应的条件变量的时间窗口中防止还没有开始监听,共享变量就发生了变化,导致信号丢失。

  • pthread_cond_wait是一个以下三个步骤的原子操作,不可中断:

    • 给mptr互斥量解锁后
    • 再阻塞监听
    • 条件变量触发后再上锁

unix_ad_49.png

  • pthread_cond_wait函数等待cond指向的条件变量,投入睡眠之前会释放mutex指向的互斥锁,唤醒后会重新获得mutex指向的互斥锁
  • pthread_cond_timewait在给定的时间内等待条件发生,超时会重新获取mutex指向的互斥锁并返回一个错误码(这个时间仍然是一个绝对时间)

3) 通知条件已经满足

unix_ad_50.png

2.4 自旋锁

  • 自旋锁和互斥量的区别:
    • 互斥量是通过休眠使进程阻塞
    • 自旋锁是在获取锁之前一直处于忙等阻塞状态
  • 自旋锁的应用场景
    • 锁被持有的时间短
    • 线程并不希望在重新调度上话费太多成本
  • 自旋锁只能被持有一小段时间,因为当线程自旋等待锁变为可用时,CPU不能做其它事情
  • 自旋锁用在非抢占式内核中的作用
    • 提供互斥机制
    • 阻塞中断,要进入中断,要先获取自旋锁,这样的内核中,中断处理程序不能休眠,所以只能使用自旋锁

1) 自旋锁的初始化

unix_ad_51.png

  • 有一个属性是自旋锁所特有的,这个属性只在支持线程进程共享同步选项的平台才用的到
    • pshared参数表示进程共享属性,表明自旋锁如何获取的。
      • PTHREAD_PROCESS_SHARED:则自旋锁能被访问锁底层的线程所获取,即便那些线程属于不同的进程
      • PTHREAD_PROCESS_PRIVATE:自旋锁只能被初始化该锁的进程内部的线程所访问

2) 自旋锁的加锁与解锁

unix_ad_52.png

  • pthread_spin_lock:在获取锁之前一直自旋
  • pthread_spin_trylock:如果不能获取锁,就立即返回EBUSY错误
  • pthread_spin_unlock:对自旋锁解锁。如果试图对没有加锁的自旋锁进行解锁,结果是未定义的

在持有自旋锁时,不要调用可能会进入休眠状态的函数。如果调用了这些函数,会浪费CPU资源,因为其他线程需要获取自旋锁需要等待的实际就延长了

2.5 屏障

屏障是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从改点继续执行

pthread_join就是一种屏障,允许一个线程等待,直到另一个线程退出

1)屏障的初始化与销毁

#include<pthread.h>

int pthread_barrier_init(pthread_barrier_t *restrict barrier,const pthread_barrierattr_t *restrict attr, unsigned int count);

int pthread_barrier_destroy(pthread_barrier_t *barrier);

  • count:在允许所有线程继续运行之前,必须到达屏障的线程数目
    • 假设主线程希望开启n个线程进行排序,每个线程排序数组的一部分,所有n个线程排好序后主线程进行归并,那么count应该为n+1
  • attr:屏障对象的属性,设为NULL时使用默认属性初始化屏障

2) 等待其它线程工作处理完

pthread_barrier_wait表明该线程已完成工作,准备等所有其它线程赶上来

#include<pthread.h>

int pthread_barrier_wait(pthread_barrier_t *barrier);

*调用pthread_barrier_wait的线程 - 在屏障计数未满足条件时,会进入休眠状态。 - 如果该线程是最后一个调用pthread_barrier_wait的线程,就满足了屏障计数,所有线程都被唤醒

一旦到达屏障计数值,而且线程处于非阻塞状态,屏障就可以被重用

只有先调用pthread_barrier_destroy函数,接着又调用pthread_barrier_init并传入一个新的计数值,否则屏障计数无法改变

九、线程控制

1.线程限制

Single UNIX Specification定义了与线程操作有关的一些限制,这些限制可以用过sysconf函数进程查询:

unix_ad_53.png

下图为apue描述的4种操作系统实现中的限制值:

unix_ad_54.png

这些限制的使用是为了增强应用程序在不同的操作系统实现之间的可移植性

测试:

printf("%ld\n",sysconf(_SC_THREAD_STACK_MIN));      //8192
printf("%ld\n",sysconf(_SC_THREAD_THREADS_MAX));    //-1(正常值,因为没有设置errno)

2.线程属性

pthread接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为,管理这些属性的函数都遵循相同的模式。

  • 应用程序不需要了解属性对象内部结构的细节,通过对属性信息数据的封装,调用访问属性信息的接口函数可以管理这些属性
    • 初始化函数:将属性设置为默认值
    • 销毁函数:释放这些资源
    • 属性获取函数:获取属性信息,成功返回0,失败返回错误编码
    • 设置属性值:按值传递设置属性值

2.1 线程属性

  • 线程的属性主要包括如下几个:
    • 线程的分离状态属性
    • 线程栈末尾的警戒缓冲区大小
    • 线程栈的最低地址
    • 线程栈的最小长度

下图为POSIX.1定义的线程属性在apue描述的4种操作系统实现中的支持情况:

unix_ad_55.png

1) 线程属性的初始化和销毁

unix_ad_56.png

  • 线程初始化将attr设置为默认值
  • 如果线程初始化pthread_attr_init的实现对属性对象的内存空间是动态分配的,则pthread_attr_destroy就会释放该内存空间

2) 线程属性的设置和获取

分离属性

分离线程:如果对现有的某个线程的终止状态不感兴趣的话,可以使用pthread_detach函数让操作系统在线程退出时收回它所占有的资源。

如果在创建线程时pthread_create就知道不需要了解线程的终止状态可以修改pthread_attr_t结构中的detachstate属性。让线程一开始就处于分离状态,detachstateyou两个合法值:PTHREAD_CREATE_DETACHED和PTHREAD_CREATE_JOINABLE.

unix_ad_57.png

线程栈的最低地址

  • 因为所有线程的栈共享进程的栈空间,所以协调好这些线程栈的大小及位置很有必要
  • 如果当进程栈分给线程栈的空间的大小使用完后,我们需要使用malloc或mmap来为可替代的栈分配空间
  • 然后使用pthread_attr_setstack来改变新建线程栈位置

unix_ad_58.png

线程栈的大小

unix_ad_59.png

  • 线程栈的大小不能小于PTHREAD_STACK_MIN

线程栈末尾警戒缓冲区的大小

  • 该缓冲区大小在线程栈末尾之后的扩展内存部分用于避免栈溢出
    • 常用值为系统页大小
    • 可以把guardsize线程属性设置为0,不允许属性的这种特征行为发生

unix_ad_60.png

3.同步属性

  • 同线程的属性一样,线程的同步对象也有属性,线程的同步对象包括:
    • 自旋锁
    • 互斥量
    • 读写锁
    • 条件变量
    • 屏障

3.1 互斥量属性

  • 互斥量有三个属性需要注意:
    • 进程共享属性
    • 健壮属性
    • 类型属性

1) 互斥量的初始化和销毁

unix_ad_61.png

同样初始化操作将互斥量属性设置为默认值

2) 互斥量的设置和获取

进程共享属性

正如之前提到的:

  • PTHREAD_PROCESS_PRIVATE:只能被初始化该互斥量的进程内部的线程所访问
  • PTHREAD_PROCESS_SHARED:可以被不同进程的线程所访问

unix_ad_62.png

健壮属性

互斥量的健壮属性与在多个进程间共享的互斥量有关。意味着当互斥量的进程终止时,需要解决互斥量健壮恢复的问题。

unix_ad_63.png

  • 参数
    • robust有两个取值
      • PTHREAD_MUTEX_STAILED(默认值):持有互斥量终止的进程终止时,不会采取特别的动作,这种情况下,使用互斥量的行为是未定义的
      • PTHREAD_MUTEX_ROBUST:
        • 导致线程调用pthread_mutex_lock获取锁时
        • 该锁被其它进程持有,但它终止时并没有对该锁进行解锁,此时线程会阻塞
        • 此时pthread_mutex_lock会返回EOWNERDEAD,而不是0.我们从该返回值可以获知,然后进行恢复

如果应用状态无法恢复,在线程对互斥锁解锁后,该互斥锁将处于永久不可用状态。为了避免这样的问题,线程可以通过调用pthread_mutex_consistent函数,指明该互斥锁相关的状态在互斥锁解锁以前是一致的

#include <pthread.h>

int pthread_mutex_consistent(pthread_mutex_t *mutex);
  • 如果线程没有先调用pthread_mutex_consistent就对互斥锁进行了解锁
  • 那么其它试图获取该互斥锁的阻塞线程就会得到错误码ENOTRECOVERABLE
  • 如果发生这种情况,互斥锁将不再可用。
  • 线程通过提前调用pthread_mutex_consistent,就能让互斥锁正常工作,这样它就可以持续被使用

类型属性的获取和设置

类型属性控制着互斥锁的锁定特性。POSIX.1定义了4种类型:

  • PTHREAD_MUTEX_NORMAL:标准类型,不做任何特殊的错误检查或死锁检测
  • PTHREAD_MUTEX_ERRORCHECK:提供错误检查
  • PTHREAD_MUTEX_RECURSIVE:允许同一线程在互斥锁解锁之前对该互斥锁进程多次加锁。递归互斥锁维护锁的计数,在解锁次数和加锁次数不相同的情况下不会释放锁
  • PTHREAD_MUTEX_DEFAULT:该类型可以提供默认特性和行为。操作系统在实现的时候可以把这种类型自由地映射到其它互斥锁类型中的一种

下图为不同的类型属性和行为:

unix_ad_64.png

设置和获取函数:

#include<pthread.h>

int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr,int *restrict type);

int pthread_mutexattr_settype(pthread_mutexattr_t * attr,int * type);

3.2 读写锁属性

读写锁属性用pthread_rwlockattr_t结构表示

进程共享属性是读写锁的唯一属性

1)读写锁属性的初始化与销毁

unix_ad_65.png

2)进程共享属性的获取与设置

进程共享属性是读写锁的唯一属性,可以通过下列函数获取与设置

unix_ad_66.png

虽然POSIX只定义了一个读写锁属性,但不同平台的实现可以自由地定义额外的、非标准的属性

3.4 条件变量属性

条件变量属性用pthread_condattr_t结构表示

Single UNIX Specification目前定义了条件变量的2个属性:

  • 进程共享属性
  • 时钟属性

1)条件变量属性的初始化与销毁

unix_ad_67.png

2)进程共享属性的获取与设置

与其他的同步属性一样,条件变量支持进程共享属性。它控制着条件变量是可以被单进程的多个线程使用,还是可以被多进程的线程使用

unix_ad_68.png

3)时钟属性的获取与设置

时钟属性控制计算pthread_cond_timedwait函数的超时参数tsptr采用的是哪个时钟

unix_ad_69.png

  • pthread_condattr_getclock:获取可被用于pthread_cond_timewait函数的时钟ID
  • pthread_condattr_setclock:对时钟ID进行修改

3.5 屏障属性

条件变量属性用pthread_barrierattr_t结构表示

进程共享属性是屏障的唯一属性

1)屏障属性的初始化与销毁

unix_ad_70.png

2)进程共享属性的获取与设置

目前定义的屏障属性只有进程共享属性,它控制着屏障是可以被多进程的线程使用,还是只能被初始化屏障的进程内的多线程使用

unix_ad_71.png

4.线程特定数据

线程的特定数据是一个线程的私有数据,是存储和查询某个特定线程相关数据的一种机制。它是每个线程自己单独的数据副本,而不需要担心与其它线程的同步访问问题。

设计特定数据的两个原因:

  1. 不能在全局设计一个简单的线程数据数组(以线程ID为索引),因为线程ID分配不是连续的整数
  2. 为了提供让“基于进程的接口适应多线程环境机制”
    • 因为进程和进程之间的数据不能相互访问,很多编程机制都是基于这一原理进行编程,所以线程同意为了适应这一套编程特点,来设置特定数据
    • 如进程的errno为全局的变量指示操作失败的原因,线程也可以将errno设置为其特有数据,这一线程设置errno不会影响到其它线程。
  • 一个线程可以访问其进程内部的整个地址空间
    • 除了寄存器,线程可以访问另一个线程的所有数据
    • 通过管理线程特定数据的函数来提高线程间的数据独立性,使线程不太容易访问其它线程数据
  • 在分配线程特定数据之前,需要创建与该数据相关联的键,这个键用于对线程特定数据的访问
#include<pthread.h>

int pthread_key_create(pthread_key_t *keyp,void (*destruct) (void*));

  • 参数
    • keyp:指向一块内存单元,将存放创建的键值
    • destruct:指向析构函数,用于析构与该键值相关联的特定数据
  • 当线程退出时,如果数据地址被置于非空值,那么析构函数就会被调用,destruct唯一参数就是该数据地址
  • 若传入的析构函数为空,则没有析构函数与其键值关联,只有在线程调用pthread_exit或线程执行返回,正常退出时,析构函数会被调用,否则析构函数不会被调用(如exit,_exit,_Exit)
  • 线程通常使用malloc来为线程特定数据分配内存,析构函数用于释放内存,线程退出时,线程特定数据的析构函数将按照操作系统实现中定义的顺序被调用。
    • 析构函数可能会调用另一个函数,该函数可能会创建新的线程特定数据。
    • 如此需要递归对这些特定数据分配的内存进行析构
    • 递归的析构次数系统具有限制,由PTHREAD_DESTRUCTOR_ITERATION来定义最大次数的尝试
  • 对所有的线程我们可以通过调用pthread_key_delete来取消键与线程的特定数据值之间的关联关系
#include<pthread.h>

int pthread_key_delete(pthread_key_t key);

pthread_key_delete并不会触发与键关联的析构函数。要释放任何与键关联的线程特定数据值的内存,需要在应用程序中采取额外的步骤

  • 需要确保分配的键并不会由于在初始化阶段的竞争而发生变动。
    • 可能两个线程都调用pthread_key_create(&key,destructor)。
    • 可能出现一个线程看到一个键值,另一个线程看到另一个键值
  • 为防止这种情况的出现,我们可以使用pthread_once来保证一个函数只被线程调用一次
#include<pthread.h>

pthread_once_t initflag = PTHREAD_ONCE_INIT;

int pthread_once(pthread_once_t *initflag,void (*initfn)(void));
  • 参数
    • initflag:必须初始化为 PTHREAD_ONCE_INIT;
    • initfn:保证线程只能调用一次该函数

键一旦创建成功后,可以和特定数据进行关联:

unix_ad_72.png

5.取消选项

  • 有两个线程属性不在pthread_attr_t结构中,它们是:
    • 可取消状态:
      • PTHREAD_CANCEL_ENABLE(默认状态)
      • PTHREAD_CANCEL_DISABLE
    • 可取消类型
      • PTHREAD_CANCEL_DEFERRED(推迟取消)
      • PTHREAD_CANCEL_ASYNCHRONOUS(异步取消)

可取消状态的修改

#include<pthread.h>

int pthread_setcancelstate(int state,int *oldstate);
  • 参数(原子操作)
    • state:将当前线程的可取消状态设置为state
    • oldstate:将当前线程的旧的可取消状态保存在oldstate
  • 取消点
    • 向线程发出一个取消请求后,该线程并不立即取消,而是继续运行
    • 只有当线程运行到一个取消点时,才会检查该线程是否被取消

POSIX.1保证在线程调用下图中列出的任何函数时,取消点都会出现:

unix_ad_73.png

  • 线程的默认取消状态为PTHREAD_CANCEL_ENABLE
    • 若设置为PTHREAD_CANCEL_DISABLE,对它发起的取消请求不会杀死该进程。
    • 取消请求会被挂起,直到线程的取消状态设置为PTHREAD_CANCEL_ENABLE。
    • 然后在下一个取消点处理所有的取消请求

当然我们可以自己设置取消点:对PTHREAD_CANCEL_DISABLE属性的线程无效

#include<pthread.h>

void pthread_testcancel(void);

可取消类型的修改

  • 可取消类型分为:
    • “推迟取消”:收到取消请求后,只在取消点才会真正取消
    • “异步取消”:在任意时间都可以被取消
#include<pthread.h>

int pthread_setcanceltype(int type,int *oldtype);

参数同上

6.线程和信号

  • 每个线程都有自己的信号屏蔽字
  • 信号处理所有线程共享:当一个线程更改信号处理行为后,所有其它线程都必须共享这一处理行为的变化
  • 线程有两种方式修改其它线程对信号的处理方式:
    • 恢复信号的默认处理行为
    • 为信号设置一个新的信号处理程序
  • 线程中的信号是送给单个线程的
    • 如果一个信号与硬件故障有关,那么该信号一般会发送到引起该事件的线程中去
    • 其它信号则被发送到任意一个线程

线程屏蔽信号操作

类似于进程的sigprocmask:

unix_ad_74.png

  • how:set与线程信号屏蔽字的作用方式
    • SIG_BLOCK:把信号集set添加到线程信号屏蔽字中
    • SIG_SETMASK:用信号集set替换线程的信号屏蔽字
    • SIG_UNBLOCK:从线程信号屏蔽字中移除信号集set
  • set:信号集
  • oset:如果不为NULL,保存线程之前的信号屏蔽字

线程可以通过把set设为NULL,把oset参数设为sigset_t结构的地址,来获取当前的信号屏蔽字。这种情况下,how参数会被忽略

等待信号的出现

unix_ad_75.png

  • 参数:
    • set指定等待的信号集
    • signop:包含发送信号的数量
  • 如果set中等待的信号集在sigwait处于挂起状态,那么sigwait将无阻塞返回
    • 返回之前会移除那些处于挂起等待状态的信号
  • 为避免错误行为的发生,在调用sigwait之前
    • 阻塞set中等待的信号,防止在sigwait调用过程中接受到正在等待的信号,判断错误
  • sigwait会原子取消信号集的阻塞状态,直到接受到新的信号,返回之前,sigwait会恢复线程的信号屏蔽字
  • 使用sigwait的好处是在于可以简化信号处理,将异步产生的信号用同步的方式处理
  • 为防止信号中断线程,可以将信号添加到该线程的信号屏蔽字中
    • 然后安排专用线程处理信号
  • 如果多个线程在sigwait的调用中因等待同一信号而阻塞,那么信号递送的时候,就只有一个线程可以从sigwait返回
  • 如果一个信号被捕获,而且一个线程正在sigwait调用中等待同一信号,那么这时将由操作系统来决定以何种方式递送信号。操作系统可以让sigwait返回,也可以激活信号处理程序,但这两种情况不会同时发生

把信号发送给线程,同进程中一样,将信号发送给进程调用kill,发送给线程调用pthread_kill

#include <signal.h>

int pthread_kill(pthread_t thread,int signo);//成功返回0,否则返回错误编号

可以传一个0值的signo来检查线程是否存在。如果信号的默认处理动作是终止该进程,那么把信号传递给某个线程仍然会杀死整个进程

闹钟定时器是进程资源,并且所有的线程共享相同的闹钟。所有,进程中的多个线程不可能互不干扰地使用闹钟定时器

7.线程和fork

  • 线程调用fork时,为子进程创建了整个进程地址空间的副本,两者没有对内存内容进行改动时,父进程和子进程之间可以共享内存页的副本(写时复制)。
  • 子进程继承整个地址空间的副本,还从父进程那继承每个互斥量、读写锁和条件变量的状态
    • 如果子进程在fork返回后没有马上调用exec的话,需要清理锁的状态
      • 因为exec会替换整个进程映像,所以锁的状态无关紧要
      • 因为子进程中只含有一个线程,那就是调用fork的线程,该子进程没有其它拥有该锁的线程副本,所以这些锁的对象需要清理
  • 所以我们要清理锁状态,需要通过调用pthread_atfork函数建立fork处理程序

unix_ad_76.png

  • prepare
    • 由父进程在fork创建子进程前调用
    • 任务是获取父进程定义的所有锁
  • parent
    • fork创建子进程以后,返回之前在父进程上下文中调用
    • 任务是对prepare fork处理程序获取的所有锁进行解锁
  • child
    • fork返回之前在子进程上下文中调用
    • prepare fork处理程序一样,也必须释放prepare fork处理程序获取的所有锁

如果不需要使用其中某个处理程序,可以给特定的处理程序参数传入空指针,它就不会起任何作用了

  • pthread_atfork可以叠加使用,从而设置多套处理程序,但三个处理程序的调用顺序有所不同:
    • parent和child fork(解锁)处理程序是以它们注册时的顺序进行调用的
    • prepare fork(获取锁)处理程序的调用顺序则与他们注册时的顺序相反。
    • 这样的顺序允许多个模块注册它们自己的fork处理程序,而且可以保持锁的层次

例子:

unix_ad_77.png

8.线程和I/O

在多线程进程中,因为线程共享相同的文件描述符,所以应该使用preadpwrite而不是readwrite,使得偏移量的设定和数据的读取成为一个原子操作

九.进程间通信

见:进程间通信