您好,欢迎访问代理记账网站
  • 价格透明
  • 信息保密
  • 进度掌控
  • 售后无忧

APUE阅读笔记

UNIX基础知识

读后感:

这一章是Unix的简介,作者用简练的语言总结了Unix的基础知识,感觉写的很清晰。

UNIX体系结构

从严格意义上说,可将操作系统定义为一种软件,它控制计算机硬件资源,提供程序运行环境。我们通常将这种软件称为内核。

内核的结构被称为系统调用

广义上说,操作系统包括了内核和一些其他软件(系统实用程序(system utility)、应用程序、shell以及公用函数库等)。

登录

系统在/etc/passwd文件中存储了登录项,登录项由7个以冒号分隔的字段组成:

  • 登录名
  • 加密口令
  • 数字用户ID
  • 数字组ID
  • 注释字段
  • 起始目录
  • shell程序

目前,所有的系统已将加密口令移动到另一个文件中。

文件与目录

UNIX文件系统是目录和文件的一种层次结构,所有东西的起点是称为根(root)的目录,这个目录的名称是一个字符“/”。

目录(directory)是一个包含目录项的文件。

目录中的各个名字称为文件名。

创建新目录时会自动创建了两个文件名:.(称为点)和…(称为点点)。点指向当前目录,点点指向父目录。在最高层次的根目录中,点点与点相同。

由斜线分隔的一个或多个文件名组成的序列(也可以斜线开头)构成路径名(pathname),以斜线开头的路径名称为绝对路径名(absolute pathname),否则称为相对路径名(relative pathname)。相对路径名指向相对于当前目录的文件。

每个进程都有一个工作目录(working directory),有时称其为当前工作目录(current working directory)。所有相对路径名都从工作目录开始解释。进程可以用chdir函数更改其工作目录。

登录时,工作目录设置为起始目录(home directory),该起始目录从口令文件中相应用户的登录项中取得。

输入和输出

文件描述符(file descriptor)通常是一个小的非负整数,内核用以标识一个特定进程正在访问的文件。当内核打开一个现有文件或创建一个新文件时,它都返回一个文件描述符。

每当运行一个新程序时,所有的 shell 都为其打开 3 个文件描述符,即标准输入(standard input)、标准输出(standard output)以及标准错误(standard error)。

函数open、read、write、lseek以及close提供了不带缓冲的I/O。这些函数都使用文件描述符。

标准I/O函数为那些不带缓冲的I/O函数提供了一个带缓冲的接口。

程序和进程

程序的执行实例被称为进程(process)。

UNIX系统确保每个进程都有一个唯一的数字标识符,称为进程ID(process ID)。进程ID总是一个非负整数。

有3个用于进程控制的主要函数:fork、exec和waitpid。(exec函数有7种变体,经常把它们统称为exec函数。)

一个进程内的所有线程共享同一地址空间、文件描述符、栈以及与进程相关的属性。因为它们能访问同一存储区,所以各线程在访问共享数据时需要采取同步措施以避免不一致性。

与进程相同,线程也用ID标识。但是,线程ID只在它所属的进程内起作用。一个进程中的线程 ID 在另一个进程中没有意义。

出错处理

UNIX系统函数出错时,通常会返回一个负值,而且整型变量errno通常被设置为具有特定信息的值。

而有些函数对于出错则使用另一种约定而不是返回负值。例如,大多数返回指向对象指针的函数,在出错时会返回一个null指针。

POSIX和ISO C将errno定义为一个符号,它扩展成为一个可修改的整形左值(lvalue)。它可以是一个包含出错编号的整数,也可以是一个返回出错编号指针的函数。以前使用的定义是:

extern int errno;

但是在支持线程的环境中,多个线程共享进程地址空间,每个线程都有属于它自己的局部errno以避免一个线程干扰另一个线程。例如,Linux支持多线程存取errno,将其定义为:

extern int *__errno_location(void);
#define errno (*__errno_location())

对于 errno 应当注意两条规则:

  1. 如果没有出错,其值不会被例程清除。因此,仅当函数的返回值指明出错时,才检验其值。
  2. 任何函数都不会将 errno 值设置为0,而且在<errno.h>中定义的所有常量都不为0。

C标准中定义了两个函数,用于打印出错信息:

#include <string.h>
char* strerror(int errnum); // 将errornum(通常就是errno值)映射为一个出错消息字符串,并返回指向该字符串的指针

#include <stdio.h>
void perror(const char *msg);   
// 基于errno的当前值,在标准错误上产生一条出错消息,然后返回
// 首先输出msg指向的字符,然后输出冒号和一个空格,接着输出对应于errno值的出错消息,最后输出一个换行符
// 通常我们以argv[0]作为参数传入,也即文件名

可将在<errno.h>中定义的各种出错分成两类:致命性的和非致命性的。

  • 对于致命性的错误,无法执行恢复动作。最多能做的是在用户屏幕上打印出一条出错消息或者将一条出错消息写入日志文件中,然后退出。
  • 对于非致命性的出错,有时可以较妥善地进行处理。大多数非致命性出错是暂时的(如资源短缺)。

用户标识

口令文件登录项中的用户ID(user ID)是一个数值,它向系统标识各个不同的用户。

用户 ID 为 0 的用户为根用户(root)或超级用户(superuser)。在口令文件中,通常有一个登录项,其登录名为 root,我们称这种用户的特权为超级用户特权。

口令文件登录项也包括用户的组ID(group ID),它是一个数值。组ID也是由系统管理员在指定用户登录名时分配的。一般来说,在口令文件中有多个登录项具有相同的组 ID。组被用于将若干用户集合到项目或部门中去。这种机制允许同组的各个成员之间共享资源(如文件)。

组文件将组名映射为数值的组ID。组文件通常是/etc/group。

除了在口令文件中对一个登录名指定一个组ID外,大多数 UNIX系统版本还允许一个用户属于另外一些组。这一功能是从4.2BSD开始的,它允许一个用户属于多至16个其他的组。登录时,读文件/etc/group,寻找列有该用户作为其成员的前 16 个记录项就可以得到该用户的附属组ID(supplementary group ID)。

信号

信号(signal)用于通知进程发生了某种情况。

进程有以下3种处理信号的方式:

  1. 忽略信号。不推荐使用这种处理方式。

  2. 按系统默认方式处理。对于除数为0,系统默认方式是终止该进程。

  3. 提供一个函数,信号发生时调用该函数,这被称为捕捉该信号。通过提供自编的函数,我们就能知道什么时候产生了信号,并按期望的方式处理它。

时间值

系统基本数据类型time_t用于保存UTC时间值(日历时间)。

系统基本数据类型clock_t用于保存CPU时间值(进程时间)。

UNIX系统为一个进程维护了3个进程时间值:

  • 时钟时间;

  • 用户CPU时间;

  • 系统CPU时间。

用户CPU时间是执行用户指令所用的时间量。

系统CPU时间是为该进程执行内核程序所经历的时间。

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

要取得进程的时钟时间、用户时间和系统时间可以执行命令time(1)。

系统调用和库函数

所有的操作系统都提供多种服务的入口点,由此程序向内核请求服务。各种版本的UNIX实现都提供良好定义、数量有限、直接进入内核的入口点,这些入口点被称为系统调用

应用程序既可以调用系统调用也可以调用库函数。很多库函数则会调用系统调用。

系统调用通常提供一种最小接口,而库函数通常提供比较复杂的功能。

Unix标准及实现

这一章前面一部分介绍了Unix操作系统的标准和实现,后面部分主要是对一些的讨论。

个人认为这一章对我这样的初级程序员来说没有那么重要,因此仅记录一点个人觉得值得一记的东西。

一些名称缩写

ANSI: American National Standards Institute, 美国国家标准学会。

ISO: International Organization for Standardization, 国际标准化组织。

IEC: International Electrotechnical Commission, 国际电子技术协会。

IEEE: Institute of Electrical and Electronic Engineers, 电气和电子工程师学会。

POSIX: Portable Operating System Interface, 可移植操作系统接口。

SUS: Single UNIX Specification, 单一UNIX规范。

XSI: X/Open System Interface, X/Open系统接口。

限制

限制可分为两种:编译时限制和运行时限制。

编译时限制可在头文件中定义,而运行时限制需要进程调用一个函数来获取限制值。

UNIX提供了以下三种限制:

  1. 编译时限制(头文件)。
  2. 与文件或目录无关的运行时限制(sysconf函数)。
  3. 与文件或目录有关的运行时限制(pathconffpathconf函数)。
#include <unistd.h>
long sysconf(int name);
long pathconf(const char* pathname, int name);  // 使用路径名作为参数
long fpathconf(int fd, int name);               // 使用文件描述符作为参数
// 以上三个函数成功返回对应值,出错返回-1
// 如果name参数并不是一个合适的常量,那么三个函数都返回-1,并把errno设置为EINVAL
// 有些name会返回一个变量值(返回值>=0)或者提示该值是不确定的,不确定的值通过返回-1来体现,而不改变errno的值

选项

对于每一个选项,有以下3种可能的平台支持状态。

  1. 如果符号常量没有定义或者定义值为−1,那么该平台在编译时并不支持相应选项。

  2. 如果符号常量的定义值大于0,那么该平台支持相应选项。

  3. 如果符号常量的定义值为0,则必须调用sysconf、pathconf或fpathconf来判断相应选项是否受到支持。在这种情况下,这些函数的name参数前缀_POSIX必须替换为_SC_PC

    对于以_XOPEN为前缀的常量,在构成name参数时必须在其前放置_SC_PC。例如,若常量_POSIX_RAW_THREADS是未定义的,那么就可以将name参数设置为SC_RAW_THREADS,并以此调用sysconf来判断该平台是否支持POSIX线程选项。如若常量_XOPEN_UNIX是未定义的,那么就可以将name参数设置为_SC_XOPEN_UNIX,并以此调用sysconf来判断该平台是否支持XSI扩展。

文件I/O

对于内核而言,所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数。当打开一个现有文件或创建一个新文件时,内核向进程返回一个文件描述符。

open和openat

调用open和openat可以打开或创建一个文件。

#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 */);
// path参数是要打开或创建文件的名字,oflag参数可用来说明此参数的多个选项
// 成功返回文件描述符,出错返回-1

oflag参数有:

  • O_RDONLYO_WRONLYO_RDWRO_EXEC(只执行打开)、O_SEARCH(只搜索打开,应用于目录),这五个常量必须且只能指定一个
  • O_APPENDO_TRUNC(截断)、O_CREAT(不存在则创建)、O_SYNC(使每次的write等待物理I/O完成,包括由该write操作引起的文件属性更新所需的I/O)、O_DSYNC(使每次write要等待物理I/O操作完成,但是如果该写操作并不影响读取刚写入的数据,则不需等待文件属性被更新)。
  • 更多内容请参考APUE第三版P50-P51。

由open和openat函数返回的文件描述符一定是最小的未用描述符数值

fd参数把open和openat函数区分开,共有3种可能性:

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

openat函数的作用:

  1. 让线程可以使用相对路径名打开目录中的文件,而不再只能打开当前工作目录。同一进程中的所有线程共享相同的当前工作目录,因此很难让同一进程的多个不同线程在同一时间工作在不同的目录中。
  2. 以避免time-of-check-to-time-of-use(TOCTTOU)错误。

TOCTTOU错误的基本思想是:如果有两个基于文件的函数调用,其中第二个调用依赖于第一个调用的结果,那么程序是脆弱的。

creat

可以使用creat函数创建一个新文件。

#include <fcntl.h>

int creat(const char* path, mode_t mode);
// 成功返回*只写打开*的文件描述符,出错返回-1
// 等效于如下open函数调用
open(path, O_WRONLY | O_CREAT | O_TRUNC, mode);

close

可以使用close函数关闭一个打开文件。

#include <unistd.h>

int close(int fd);
// 成功返回0,出错返回-1

关闭一个文件时还会释放该进程加在该文件上的所有记录锁。

当一个进程终止时,内核自动关闭它所有的打开文件。很多程序都利用了这一功能而不显式地用close关闭打开文件。

lseek

可以使用lseek函数来设置文件偏移量。

#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);
// 若成功,返回新的文件偏移量,出错则返回-1

对参数offset的解释与参数whence的值有关:

  • 若whence是SEEK_SET,则将该文件的偏移量设置为距文件开始处offset个字节。
  • 若whence是SEEK_CUR,则将该文件的偏移量设置为其当前值加offset,offset可为正或负。
  • 若whence是SEEK_END,则将该文件的偏移量设置为文件长度加offset,offset可正可负。

查看当前偏移量:

off_t currpos;
currpos = lseek(fd, 0, SEEK_CUR);

read

调用read函数从打开文件中读取数据。

#include <unistd.h>

ssize_t read(int fd, void* buf, size_t nbytes);
// 返回读到的字节数,若已经读到文件尾,则返回0,出错返回-1

write

调用write函数向打开文件中写数据。

#include <unistd.h>

ssize_t write(int fd, const void* buf, size_t nbytes);
// 返回已写的字节数,出错则返回-1

文件共享

UNIX系统支持在不同进程间共享打开文件。

内核使用3种数据结构表示打开文件:

  1. 每个进程在进程表中都有一个记录项,记录项中包含一张打开文件描述符表,可将其视为一个矢量,每个描述符占用一项。与每个文件描述符相关联的是:

    • 文件描述符标志;
    • 指向一个文件表项的指针。
  2. 内核为所有打开文件维持一张文件表。每个文件表项包含:

    • 文件状态标志(读、写、添写、同步和非阻塞等);
    • 当前文件偏移量;
    • 指向该文件v节点表项的指针。
  3. 每个打开文件(或设备)都有一个 v 节点(v-node)结构。v节点包含了文件类型和对此文件进行各种操作函数的指针。对于大多数文件,v节点还包含了该文件的i节点(i-node,索引节点)。i 节点包含了文件的所有者、文件长度、指向文件实际数据块在磁盘上所在位置的指针等。

Linux没有使用v节点,而是使用了通用i节点结构。虽然两种实现有所不同,但在概念上,v节点与i节点是一样的。两者都指向文件系统特有的i节点结构。

对前面操作的一些说明:

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

原子操作

一般而言,原子操作(atomic operation)指的是由多步组成的一个操作。如果该操作原子地执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。

pread和pwrite可以原子性的定位并执行I/O(可用于多线程环境):

#include <unistd.h>

ssize_t pread(int fd, void* buf, size_t nbytes, off_t offset);
// 返回读到的字节数,若已到达文件尾则返回0,出错返回-1
ssize_t pwrite(int fd, const void* buf, size_t nbytes, off_t offset);
// 成功返回读到的字节数,出错返回-1

dup与dup2

可以通过dup和dup2函数来复制一个现有的文件描述符。

#include <unistd.h>

int dup(int fd);
int dup2(int fd, int fd2);
// 成功则返回新的文件描述符,出错则返回-1

// 其中dup等效于
fcntl(fd, F_DUPFD, 0); 
// dup2等效于:
close(fd2);
fcntl(fd, F_DUPFD, fd2);
// 不过dup2是原子的,而上述函数并不是

由dup返回的新文件描述符一定是当前可用文件描述符中的最小数值。

对于dup2,可以用fd2参数指定新描述符的值:

  • 如果fd2已经打开,则先将其关闭。
  • 如若fd等于fd2,则dup2返回fd2,而不关闭它。
  • 否则,fd2的FD_CLOEXEC文件描述符标志就被清除,这样fd2在进程调用exec时是打开状态。

sync、fsync和fdatasync

可以使用sync、fsync和fdatasync三个函数来保证磁盘上实际文件系统与缓冲区中内容的一致性。

#include <unistd.h>

int fsync(int fd);
int fdatasync(int fd);
// 以上两个函数若成功则返回0,失败则返回-1
void sync(void);

sync只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘操作结束。

通常,称为update的系统守护进程周期性地调用(一般每隔30秒)sync函数。这就保证了定期冲洗(flush)内核的块缓冲区。命令sync(1)也调用sync函数。

fsync函数只对由文件描述符fd指定的一个文件起作用,并且等待写磁盘操作结束才返回。

fsync可用于数据库这样的应用程序,这种应用程序需要确保修改过的块立即写到磁盘上。

fdatasync函数类似于fsync,但它只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。

fcntl

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

#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* int arg */);
// 成功的返回值依赖于cmd,出错返回-1

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)。

查看状态标志时需要结合屏蔽字O_ACCMODE,使用方式可参考下方代码:

#include "apue.h"
#include <fcntl.h>

// the function of 3-12
// 为文件描述符fd添加flags标志
void set_fl(int fd, int flags) {
    int val;
    if ((val = fcntl(fd, F_GETFL, 0)) < 0) {
        err_sys("fcntl F_GETFL error");
    }
    val |= flags;
    if (fcntl(fd, F_SETFL, val) < 0) {
        err_sys("fcntl F_SETFL error");
    }
}

int main(int argc, char* argv[]) {
    int val;
    if (argc != 2) {
        err_quit("usage: %s <descriptor>", argv[0]);
    }
    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");
        case O_RDWR:
            printf("read write");
            break;
        default:
            err_dump("unknown access mode");
    }
    if (val & O_APPEND) {
        printf(", append");
    }
    if (val & O_NONBLOCK) {
        printf(", nonblockint");
    }
    if (val & O_SYNC) {
        printf("synchronous writes");
    }
#if !defined(_POSIX_C_SOURCE) && defined(O_FSYNC) && (O_FSYNC != O_SYNC)
    if (val & O_FSYNC) {
        printf(", synchronous writes");
    }
#endif
    putchar('\n');
    exit(0);
}

程序运行时,设置O_SYNC标志会增加系统时间和时钟时间。

ioctl

ioctl是I/O操作的杂物箱,终端I/O是使用ioctl最多的地方。

#include <unistd.h>
#include <sys/ioctl.h>

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

/dev/fd

较新的系统都提供名为/dev/fd 的目录,其目录项是名为 0、1、2 等的文件。打开文件/dev/fd/n等效于复制描述符n(假定描述符n是打开的)。

文件和目录

stat、fstat、fstatat、lstat

#include <sys/stat.h>

int stat(const char* restric pathname, struct stat* restrict buf);
int fstat(inf fd, struct stat* buf);
int lstat(const char* restrict pathname, strcut stat* restrict buf);
int fstatat(int fd, const char* restrict pathname, struct stat* restrict buf, int flag);
// 若成功则返回0,否则返回-1

lstat函数类似于stat,但是当命名的文件是一个符号链接时,lstat返回该符号链接的有关信息,而不是由该符号链接引用的文件的信息。

fstatat函数为一个相对于当前打开目录(由fd参数指向)的路径名返回文件统计信息。

  • flag参数控制着是否跟随着一个符号链接。当AT_SYMLINK_NOFOLLOW标志被设置时,fstatat不会跟随符号链接,而是返回符号链接本身的信息。否则,在默认情况下,返回的是符号链接所指向的实际文件的信息。

  • 如果fd参数的值是AT_FDCWD,并且pathname参数是一个相对路径名,fstatat会计算相对于当前目录的pathname参数。

  • 如果pathname是一个绝对路径,fd参数就会被忽略。

这后面两种情况下,根据flag的取值,fstatat的作用就跟stat或lstat一样。

备注:后续有at的函数,规则都和这个差不多。

struct stat结构的基本形式:

struct stat {
    mode_t st_mode; 			/* file type & mode (permissions) */
    ino_t st_ino;				/* i-node number (serial number) */
    dev_t st_dev; 				/* device number (file system) */
    dev_t st_rdev;				/* device number for special files */
    nlink_t	st_nlink;			/* number of links */
    uid_t	st_uid;				/* user ID of owner */
    gid_t	st_gid;				/* group ID of owner */
    off_t	st_size;			/* size in bytes, for regular files */
    struct timespec	st_atime;	/* time of last access */
    struct timespec	st_mtime;	/* time of last modification */
    struct timespec	st_ctime;	/* time of last file status change */
    blksize_t	st_blksize;		/* best I/O block size */
    blkcnt_t	st_blocks;		/* number of disk blocks allocated */
};

文件类型

  1. 普通文件(regular file)。最常用的文件类型,这种文件包含了某种形式的数据。至于这种数据是文本还是二进制数据,对于UNIX内核而言并无区别。对普通文件内容的解释由处理该文件的应用程序进行。
  2. 目录文件(directory file)。这种文件包含了其他文件的名字以及指向与这些文件有关信息的指针。对一个目录文件具有读权限的任一进程都可以读该目录的内容,但只有内核可以直接写目录文件
  3. 块特殊文件(block special file)。这种类型的文件提供对设备(如磁盘)带缓冲的访问,每次访问以固定长度为单位进行。
  4. 字符特殊文件(character special file)。这种类型的文件提供对设备不带缓冲的访问,每次访问长度可变。系统中的所有设备要么是字符特殊文件,要么是块特殊文件。
  5. FIFO。这种类型的文件用于进程间通信,有时也称为命名管道(named pipe)。
  6. 套接字(socket)。这种类型的文件用于进程间的网络通信。套接字也可用于在一台宿主机上进程之间的非网络通信。
  7. 符号链接(symbolic link)。这种类型的文件指向另一个文件。

一个判断文件类型的程序:

#include "apue.h"

int main(int argc, char *argv[]) {
    struct stat buf;
    char *ptr;
    for (int i = 1; i < argc; i++) {
        printf("%s: ", argv[i]);
        if (lstat(argv[i], &buf) < 0) {
            err_ret("lstat error");
            continue;
        }
        if (S_ISREG(buf.st_mode))
            ptr = "regular";
        else if (S_ISDIR(buf.st_mode))
            ptr = "directory";
        else if (S_ISCHR(buf.st_mode))
            ptr = "character special";
        else if (S_ISBLK(buf.st_mode))
            ptr = "block special";
        else if (S_ISFIFO(buf.st_mode))
            ptr = "fifo";
        else if (S_ISLNK(buf.st_mode))
            ptr = "symbolic link";
        else if (S_ISSOCK(buf.st_mode))
            ptr = "socket";
        else
            ptr = "** unknown mode **";
        printf("%s\n", ptr);
    }
    exit(0);
}

设置用户ID和设置组ID

当执行一个程序文件时,进程的有效用户ID通常就是实际用户ID,有效组ID通常是实际组ID。

可以在文件模式字(st_mode)中设置一个特殊标志,其含义是“当执行此文件时,将进程的有效用户ID设置为文件所有者的用户ID(st_uid)”。

还可以在文件模式字中可以设置另一位,它将执行此文件的进程的有效组ID设置为文件的组所有者ID(st_gid)。

在文件模式字中的这两位被称为设置用户ID(set-user-ID)位设置组ID(set-group-ID)位

这两位包含在文件的st_mode值中,分别可用常量S_ISUIDS_ISGID进行测试。

文件访问权限

  • S_IRUSR: 用户读,S_IWUSR: 用户写,S_IXUSR: 用户执行。

  • S_IRGRPS_IWGRPS_IXGRP(组)

  • S_IROTHS_IWOTHS_IXOTH(其他)

权限的使用规则:

  • 我们用名字打开任一类型的文件时,对该名字中包含的每一个目录,包括它可能隐含的当前工作目录都应具有执行权限。(目录其执行权限位常被称为搜索位)
  • 对于一个文件的读权限决定了我们是否能够打开现有文件进行读操作。这与open函数的O_RDONLY和O_RDWR标志相关。
  • 对于一个文件的写权限决定了我们是否能够打开现有文件进行写操作。这与open函数的O_WRONLY和O_RDWR标志相关。
  • 为了在open函数中对一个文件指定O_TRUNC标志,必须对该文件具有写权限。
  • 为了在一个目录中创建一个新文件,必须对该目录具有写权限和执行权限。
  • 为了删除一个现有文件,必须对包含该文件的目录具有写权限和执行权限。对该文件本身则不需要有读、写权限。
  • 如果用7个exec函数中的任何一个执行某个文件,都必须对该文件具有执行权限。该文件还必须是一个普通文件。

进程每次打开、创建或删除一个文件时,内核就进行文件访问权限测试:

  • 若进程的有效用户ID是0(超级用户),则允许访问。
  • 若进程的有效用户ID等于文件的所有者ID(也就是进程拥有此文件),那么如果所有者适当的访问权限位被设置,则允许访问;否则拒绝访问。
  • 若进程的有效组ID或进程的附属组ID之一等于文件的组ID,那么如果组适当的访问权限位被设置,则允许访问;否则拒绝访问。
  • 若其他用户适当的访问权限位被设置,则允许访问;否则拒绝访问。

新文件和目录的所有权

新文件的用户ID设置为进程的有效用户ID。关于组ID,POSIX.1允许实现选择下列之一作为新文件的组ID:

  1. 新文件的组ID可以是进程的有效组ID。

  2. 新文件的组ID可以是它所在目录的组ID。

access和faccessat

可以使用access函数来按照实际用户ID和实际组ID来进行访问权限测试。

#include <unistd.h>

int access(const char* pathname, int mode);
int faccessat(int fd, const char* pathname, int mode, int flag);
// 成功返回0,出错返回-1

mode可为:R_OKW_OKX_OK,分别测试读、写、执行权限。

accessat函数与access函数在下面两种情况下是相同的:

  • pathname为绝对路径;
  • fd参数取值为AT_FDCWD而pathname参数为相对路径。

否则,faccessat计算相对于打开目录(由fd参数指向)的pathname。

flag参数可以用于改变faccessat的行为,如果flag设置为AT_EACCESS,访问检查用的是调用进程的有效用户ID和有效组ID,而不是实际用户ID和实际组ID。

umask

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

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

umask值表示成八进制数,一位代表一种要屏蔽的权限。

chmod, fchmod, fchmodat

我们可以使用chmod函数来更改现有文件的权限。

#include <sys/stat.h>

int chmod(const char* pathname, mode_t);
int fchmod(int fd, mode_t mode);
int fchmodat(int fd, const char* pathname, mode_t mode, int flag);

fchmodatchmod函数的区别可参考fstatatstat的区别。

使用示例:

#include "apue.h"

int main(int argc, char **argv) {
    struct stat statbuf;
    if (stat("foo", &statbuf) < 0) {
        err_sys("stat error for foo");
    }
    // 打开set-group-ID,关闭group-execute
    if (chmod("foo", (statbuf.st_mode & ~S_IXGRP) | S_ISGID) < 0) {
        err_sys("chmod error for foo");
    }
    if (chmod("bar", S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) < 0) {
        err_sys("chmod error for bar");
    }
    return 0;
}

chmod函数在下列条件下自动清除两个权限位:

  • Solaris等系统对用于普通文件的粘着位赋予了特殊含义,在这些系统上如果我们试图设置普通文件的粘着位(S_ISVTX),而且又没有超级用户权限,那么mode中的粘着位自动被关闭。

  • 如果新文件的组ID不等于进程的有效组ID或者进程附属组ID中的一个,而且进程没有超级用户权限,那么设置组ID位会被自动被关闭。

粘着位

粘着位(S_ISVTX)原本用于保存正文,以提高效率。现今的系统扩展了粘着位的使用范围,如果对一个目录设置了粘着位,只有对该目录具有写权限的用户并且满足下列条件之一,才能删除或重命名该目录下的文件:

  • 拥有此文件;
  • 拥有此目录;
  • 是超级用户。

chown、fchown、fchownat、lchown

可以使用chown函数来修改文件的用户ID和组ID。

#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 lchown(const char* pathname, uid_t owner, gid_t group);
// 成功返回0,失败返回-1

函数之间的区别可参考stat系列函数。

文件长度

stat结构成员st_size表示以字节为单位的文件的长度。此字段只对普通文件、目录文件和符号链接有意义。

  • 对于普通文件,其文件长度可以是0,在开始读这种文件时,将得到文件结束(end-of-file)指示。
  • 对于目录,文件长度通常是一个数(如16或512)的整倍数。
  • 对于符号链接,文件长度是在文件名中的实际字节数。

现今,大多数现代的UNIX系统提供字段st_blksize和st_blocks。其中,第一个是对文件I/O较合适的块长度,第二个是所分配的实际512字节块块数。

文件截断

可以使用truncate函数来截断文件到指定长度length。

#include <unistd.h>

int truncate(const char* pathname, off_t length);
int ftruncate(int fd, off_t length);
// 成功返回0,失败返回-1

文件系统

我们可以把一个磁盘分成一个或多个分区。每个分区可以包含一个文件系统。i节点是固定长度的记录项,它包含有关文件的大部分信息。

  • 每个i节点中都有一个链接计数,其值是指向该i节点的目录项数。只有当链接计数减少至0时,才可删除该文件。链接计数包含在stat结构的nlink_t成员中。这种链接类型被称为硬链接
  • 另外一种链接类型称为符号链接(symbolic link)。符号链接文件的实际内容(在数据块中)包含了该符号链接所指向的文件的名字。
  • i节点包含了文件有关的所有信息:文件类型、文件访问权限位、文件长度和指向文件数据块的指针等。stat结构中的大多数信息都取自i节点。只有两项重要数据存放在目录项中:文件名和i节点编号。
  • 因为目录项中的i节点编号指向同一文件系统中的相应i节点,一个目录项不能指向另一个文件系统的i节点。
  • 当在不更换文件系统的情况下为一个文件重命名时,该文件的实际内容并未移动,只需构造一个指向现有i节点的新目录项,并删除老的目录项。链接计数不会改变。

link、linkat、unlink、unlinkat、remove

#include <unistd.h>

int link(const char* existingpath, const char* newpath);
int linkat(int efd, const char* existingpath, int nfd, const char* newpath, int flag);
// 成功返回0,失败返回-1
// 两者的区别可参考stat和fstatat
// 这两个函数创建一个新目录项newpath,它引用现有文件existingpath。如果newpath已经存在,则返回出错。只创建newpath中的最后一个分量,路径中的其他部分应当已经存在。

int unlink(const char* pathname);
int unlinkat(int fd, const char* pathname, int flag);
// 成功返回0,出错返回-1
// 这两个函数解除对文件的链接

#include <stdio.h>

int remove(const char* pathname);
// 成功返回0,出错返回-1
// 对于文件,remove的作用与unlink相同,对于目录,remove的作用与rmdir相同

rename和renameat

可以使用rename函数来重命名文件和目录。

#include <stdio.h>

int rename(const char* oldname, const char* newname);
int renameat(int oldfd, const char* oldname, int newfd, const char* newname);

重命名的情况:

  • 如果oldname指的是一个文件而不是目录,那么为该文件或符号链接重命名。在这种情况下,如果newname已存在,则它不能引用一个目录。如果newname已存在,而且不是一个目录,则先将该目录项删除然后将oldname重命名为newname。对包含oldname的目录以及包含newname的目录,调用进程必须具有写权限,因为将更改这两个目录。
  • 如若oldname指的是一个目录,那么为该目录重命名。如果newname已存在,则它必须引用一个目录,而且该目录应当是空目录。如果newname存在(而且是一个空目录),则先将其删除,然后将oldname重命名为newname。另外,当为一个目录重命名时,newname不能包含oldname作为其路径前缀。例如,不能将/usr/foo重命名为/usr/foo/testdir,因为旧名字(/usr/foo)是新名字的路径前缀,因而不能将其删除。
  • 如若oldname或newname引用符号链接,则处理的是符号链接本身,而不是它所引用的文件。
  • 不能对.和…重命名。更确切地说,.和…都不能出现在oldname和newname的最后部分。
  • 作为一个特例,如果oldname和newname引用同一文件,则函数不做任何更改而成功返回。

符号链接

符号链接是对一个文件的间接指针,它与硬链接有所不同,硬链接直接指向文件的i节点。引入符号链接的原因是为了避开硬链接的一些限制:

  • 硬链接通常要求链接和文件位于同一文件系统中。

  • 只有超级用户才能创建指向目录的硬链接(在底层文件系统支持的情况下)。

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

运行ls命令,并使用-F选项时,符号链接后面会出现一个@符号

创建和读取符号链接

可以使用symlink函数创建一个符号链接。

#include <unistd.h>

int symlink(const char* actualpath, const char* sympath);
int symlinkat(const char* actualpath, const char* sympath);
// 成功返回0,出错返回-1
// 函数创建了一个指向actualpath的新目录项sympath。

readlink提供了读取符号链接本身内容的功能(不会像open一样跟随链接)。

#include <unistd.h>

ssize_t readlink(const char* restrict pathname, char* restrict buf, size_t bufsize);
ssize_t readlinkat(int fd, const char* restrict pathname, char* restrict buf. size_t bufsize);
// 成功返回读取的字节数,出错返回-1

文件时间

每个文件维护了3个时间字段:

  • st_atime: 文件数据的最后访问时间
  • st_mtime: 文件数据的最后更改时间
  • st_ctime: i节点状态的最后更改时间

ls -u选项按访问时间排序,-c选项则按状态更改时间排序。

futimens, utimensat, utimes

可以使用以下函数修改一个文件的访问和修改时间。

#include <sys/stat.h>

int futimens(int fd, const struct timespect times[2]);
int utimensat(int fd, const char* pathm const struct timespec times[2], int flag);
// 成功返回0,出错返回-1

时间戳的指定方式:

  • 如果times参数是一个空指针,则访问时间和修改时间两者都设置为当前时间。
  • 如果times参数指向两个timespec结构的数组,任一数组元素的tv_nsec字段的值为UTIME_NOW,相应的时间戳就设置为当前时间,忽略相应的tv_sec字段。
  • 如果times参数指向两个timespec结构的数组,任一数组元素的tv_nsec字段的值为UTIME_OMIT,相应的时间戳保持不变,忽略相应的tv_sec字段。
  • 如果 times 参数指向两个 timespec 结构的数组,且 tv_nsec 字段的值为既不是UTIME_NOW 也不是 UTIME_OMIT,在这种情况下,相应的时间戳设置为相应的 tv_sec 和tv_nsec字段的值。

执行这些函数需要的权限:

  • 如果times是一个空指针,或者任一tv_nsec字段设为UTIME_NOW,则进程的有效用户ID必须等于该文件的所有者ID;进程对该文件必须具有写权限,或者进程是一个超级用户进程。
  • 如果 times 是非空指针,并且任一 tv_nsec 字段的值既不是 UTIME_NOW 也不是UTIME_OMIT,则进程的有效用户ID必须等于该文件的所有者ID,或者进程必须是一个超级用户进程。
  • 如果times是非空指针,并且两个tv_nsec字段的值都为UTIME_OMIT,就不执行任何的权限检查。
#include <sys/time.h>

int utimes(const char* pathname, const struct timeval times[2]);
// 成功返回0,出错返回-1
// 其中timeval的结构如下:
struct timeval {
    time_t tv_sec;  // seconds
    long tv_usec;   // microseconds
};

mkdir, mkdirat, rmdir

使用mkdir函数创建目录,使用rmdir函数来删除一个空目录

#include <sys/stat.h>

int mkdir(const char* pathname, mode_t mode);
int mkdirat(int fd, const char* pathname, mode_t mode);
int rmdir(const char* pathname);
// 成功返回0,出错返回-1

读目录

#include <dirent.h>

DIR* opendir(const char* pathname);
DIR* fdopendir(int fd); // 为什么不叫fopendir呢?奇怪。
// 成功返回指针,出错返回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);

chdir, fchdir, getcwd

进程可以调用chdir来改变当前工作目录。

#include <unistd.h>

int chdir(const char* pathname);
int fchdir(int fd);
// 成功返回0,失败返回-1

因为当前工作目录是进程的一个属性,所以它只影响调用 chdir 的进程本身,而不影响其他进程。

每个程序运行在独立的进程中,shell 的当前工作目录并不会随着程序调用chdir而改变。由此可见,为了改变shell进程自己的工作目录,shell应当直接调用chdir函数,为此,cd命令内建在shell中。

可以使用getcwd来获取当前目录。

#include <unistd.h>

char* getcwd(char* buf, size_t size);
// 成功返回buf, 出错返回NULL

设备特殊文件

st_dev和st_rdev:

  • 每个文件系统所在的存储设备都由其主、次设备号表示。设备号所用的数据类型是基本系统数据类型dev_t。主设备号标识设备驱动程序,有时编码为与其通信的外设板;次设备号标识特定的子设备。

  • 我们通常可以使用两个宏:major和minor来访问主、次设备号。

  • 系统中与每个文件名关联的 st_dev 值是文件系统的设备号,该文件系统包含了这一文件名以及与其对应的i节点。

  • 只有字符特殊文件和块特殊文件才有st_rdev值。此值包含实际设备的设备号。

流和FILE对象

对于标准I/O库,它们的操作是围绕流(stream)进行的。

标准I/O文件流可用于单字节或多字节(“宽”)字符集。

流的定向(stream’s orientation)决定了所读、写的字符是单字节还是多字节的。

  • 当一个流最初被创建时,它并没有定向。
  • 如若在未定向的流上使用一个多字节I/O函数,则将该流的定向设置为宽定向的。
  • 若在未定向的流上使用一个单字节I/O函数,则将该流的定向设为字节定向的。

只有两个函数可改变流的定向。freopen函数清除一个流的定向;fwide函数可用于设置流的定向。

#include <stdio.h>
#include <wchar.h>

int fwide(FILE* fp, int mode);
// 若流是宽定向的,那么返回正值
// 若流是字节定向的,返回负值
// 若流是为定向的,那么返回0

根据mode参数的不同值,fwide函数执行不同的工作。

  • 如若mode参数值为负,fwide将试图使指定的流是字节定向的。
  • 如若mode参数值为正,fwide将试图使指定的流是宽定向的。
  • 如若mode参数值为0,fwide将不试图设置流的定向,但返回标识该流定向的值。

fwide并不改变已定向流的定向。

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

标准输入、标准输出、标准错误

对一个进程预定义了三个流,这三个流可以自动的被进程使用,这3个标准I/O流通过预定义文件指针stdinstdoutstderr加以引用(定义在头文件<stdio.h>中)。

缓冲

标准I/O提供了以下3种类型的缓冲。

  1. 全缓冲。填满标准I/O缓冲区后才进行实际I/O操作。对于驻留在磁盘上的文件通常是由标准I/O库实施全缓冲的。
  2. 行缓冲。当在输入和输出中遇到换行符时,标准I/O库执行I/O操作。当流涉及一个终端时(如标准输入和标准输出),通常使用行缓冲。
  3. 不带缓冲。标准I/O库不对字符进行缓冲存储。

对于行缓冲有两个限制:

  • 只要填满了缓冲区,那么即使还没有写一个换行符,也进行I/O操作。
  • 任何时候只要通过标准I/O 库要求从一个不带缓冲的流,或者一个行缓冲的流(它从内核请求需要数据)得到输入数据,那么就会冲洗所有行缓冲输出流。

标准错误流stderr通常是不带缓冲的,这就使得出错信息可以尽快显示出来,而不管它们是否含有一个换行符。

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

#include <stdio.h>

void setbuf(FILE* restrict fp, char* restric buf);
int setvbuf(FILE* restrict fp, char* restric buf, int mode, size_t size);
// 成功返回0,出错返回非0

可以使用setbuf函数打开或关闭缓冲机制:

  • 为了带缓冲进行I/O,参数buf必须指向一个长度为BUFSIZ的缓冲区(该常量定义在<stdio.h>中)。通常在此之后该流就是全缓冲的。
  • 为了关闭缓冲,可将buf设置为NULL。

使用setvbuf,我们可以通过指定mode参数来精确地说明所需的缓冲类型:

  • _IOFBF 全缓冲
  • _IOLBF 行缓冲
  • _IONBF 不带缓冲

如果指定一个不带缓冲的流,则忽略buf和size参数。

如果指定全缓冲或行缓冲,则buf和size可选择地指定一个缓冲区及其长度。

如果该流是带缓冲的,而buf是NULL,则标准I/O库将自动地为该流分配适当长度的缓冲区。适当长度指的是由常量BUFSIZ所指定的值。

强制冲洗一个流:

#include <stdio.h>

int fflush(FILE* fp);
// 成功返回0,失败返回EOF

此函数导致该流所有未写的数据都被传送到内核。

打开流

可以使用以下函数打开一个标准I/O流:

#include <stdio.h>

FILE* fopen(const char* restric pathname, const char* restrict type);
FILE* freopen(const char* restrict pathname, const char* restrict type, FILE* restrict fp);
FILE* fdopen(int fd, const char* type);
// 成功返回文件指针,出错返回NULL
  • fopen函数打开路径名为pathname的一个指定的文件。

  • freopen函数在一个指定的流上打开一个指定的文件,如若该流已经打开,则先关闭该流。若该流已经定向,则使用freopen清除该定向

    此函数一般用于将一个指定的文件打开为一个预定义的流:标准输入、标准输出或标准错误。

  • fdopen函数取一个已有的文件描述符,并使一个标准的I/O流与该描述符相结合。

    此函数常用于由创建管道和网络通信通道函数返回的描述符。

type参数指定对该I/O流的读、写方式:

image-20210428002826965

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

  • 如果中间没有fflush、fseek、fsetpos或rewind,则在输出的后面不能直接跟随输入。
  • 如果中间没有fseek、fsetpos或rewind,或者一个输入操作没有到达文件尾端,则在输入操作之后不能直接跟随输出。

可以调用fclose关闭一个打开的流:

#include <stdio.h>

int fclose(FILE* fp);
// 成功返回0,出错返回EOF

在文件被关闭之前,冲洗缓冲中的输出数据。缓冲区中的任何输入数据被丢弃。如果标准I/O库已经为该流自动分配了一个缓冲区,则释放此缓冲区。

当一个进程正常终止时(直接调用exit函数,或从main函数返回),则所有带未写缓冲数据的标准I/O流都被冲洗,所有打开的标准I/O流都被关闭。

读和写流

可以使用getc函数来一次读一个字符:

#include <stdio.h>

int getc(FILE* fp);
int fgetc(FILE* fp);
int getchar(void);
// 成功返回下一个字符,若已到达文件尾端或者出错,返回EOF

函数getchar等同于getc(stdin)。

getc与fgetc的区别是:getc可被实现为宏,而fgetc不能实现为宏。

可以使用以下两个函数来判断是否出错和到达文件尾端:

#include <stdio.h>

int ferror(FILE* fp);
int feof(FILE* fp);
// 条件为真返回非0,否则返回0

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

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

可以使用clearerr函数清除这两个标志:

#include <stdio.h>

void clearerr(FILE* fp);

可以使用ungetc()将字符压送回流中:

#include <stdio.h>

int ungetc(int c, FILE* fp);
// 成功返回c, 出错返回EOF

压送回到流中的字符以后又可从流中读出,但读出字符的顺序与压送回的顺序相反。

一次成功的ungetc调用会清除该流的文件结束标志,所以已经到达文件尾端时,仍可以回送一个字符。下次读将返回该字符,再读则返回EOF。

用ungetc压送回字符时,并没有将它们写到底层文件中或设备上,只是将它们写回标准I/O库的流缓冲区中。

可以使用putc函数来一次输出一个字符:

#include <stdio.h>

int putc(int c, FILE* fp);
int fputc(int c, FILE* fp);
int putchar(int c);
// 成功返回c,出错返回EOF

putchar©等同于putc(c, stdout),putc可被实现为宏,而fputc不能实现为宏。

每次一行I/O

可以使用fgets函数来提供每次输入一行的功能:

#include <stdio.h>

char* fgets(char* restrict buf, int n, FILE* restrict fp);
char* gets(char* buf);  // 不推荐使用,可能造成缓冲区溢出
// 成功返回buf,若已到达文件尾或出错,则返回NULL

gets删除换行符,fgets保留换行符。

可以使用fputs函数来提供每次输出一行的功能:

#include <stdio.h>

int fputs(const char* restrict str, FILE* restrict fp);
int puts(const char* str);
// 成功返回非负值,出错返回EOF
  • fputs将一个以null字节终止的字符串写到指定的流,尾端的终止符null不写出。
  • puts将一个以null字节终止的字符串写到标准输出,终止符不写出。puts随后又将一个换行符写到标准输出。

二进制I/O

可以使用fread和fwrite来执行二进制I/O操作:

#include <stdio.h>

size_t fread(void* restrict ptr, size_t size, size_t n, FILE* restrict fp);
size_t fwrite(const void* restrict ptr, size_t size, size_t n, FILE* restrict fp);
// 返回读或写的对象数

定位流

有3种方法定位标准I/O流:

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

需要移植到非UNIX系统上运行的应用程序应当使用fgetpos和fsetpos。

#include <stdio.h>

long ftell(FILE* fp);
// 成功返回当前文件位置只是,出错返回-1L
int fseek(FILE* fp, long offset, int whence);
// 成功返回0,出错返回-1
void rewind(FILE* fp);

为了定位一个文本文件,whence一定要是SEEK_SET,而且offset只能有两种值:0(后退到文件的起始位置),或是对该文件的ftell所返回的值。

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

#include <stdio.h>

off_t ftello(FILE* fp);
// 成功返回当前文件位置,出错返回(off_t)-1
off_t fseeko(FILE* fp, off_t offset, int whence);
// 成功返回0,出错返回-1

除了偏移量的类型是off_t而非long以外,ftello函数与ftell相同,fseeko函数与fseek相同。

#include <stdio.h>

int fgetpos(FILE* restrict fp, fpos_t* restrict pos);
int fsetpos(FILE* fp, const fpos_t* pos);
// 成功返回0,出错返回非0

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

格式化I/O

可以使用printf函数来进行格式化输出:

#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, ...);
// 若缓冲区足够大,返回将要存入数组的字符数,出错返回负值
  • printf将格式化数据写到标准输出。

  • fprintf写至指定的流。

  • dprintf写至指定的文件描述符。

  • sprintf将格式化的字符送入数组buf中。

    sprintf在该数组的尾端自动加一个null字节,但该字符不包括在返回值中。

格式说明符不再详述,详细内容请参考APUE第三版P128-P129。

printf族的变体:

#include <stdarg.h>
#include <stdio.h>

int vprintf(const char* restrict format, va_list arg);
int vfprintf(FILE* restrict fp, const char* restrict format, va_list arg);
int vdprintf(int fd, const char* restrict format, va_list arg);
// 以上三个函数成功返回输出字符数,出错返回负值
int vsprintf(char* restrict buf, const char* restrict format, va_list arg);
// 成功返回存入数组的字符数,出错返回负值
int vsnprintf(char* restrict buf, size_t n, const char* restrict format, va_list arg);
// 若缓冲区足够大,返回将要存入数组的字符数,出错返回负值

可以使用scanf函数进行格式化输入:

#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

格式说明符不再详述,详细内容请参考APUE第三版P130。

scanf族的变体:

#include <stdarg.h>
#include <stdio.h>

int vscanf(const char* restrict format, va_list arg);
int vfscanf(FILE* restrict fp, const char* restrict format, va_list arg);
int vsscanf(const char* restrict buf, const char* restrict format, va_list arg);
// 返回赋值的输入项数,若输入出错或在任一转换前已经到达文件尾端,则返回EOF

实现细节

我们可以对一个流使用fileno函数来获取它的描述符:

#include <stdio.h>

int fileno(FILE* fp);
// 返回与流相关联的文件描述符

临时文件

可以使用以下两个函数来帮助创建临时文件:

#include <stdio.h>

char* tmpnam(char* ptr);
// 返回指向唯一路径名的指针
FILE* tmpfile(void);
// 成功返回文件指针,出错返回NULL

tmpnam函数产生一个与现有文件名不同的一个有效路径名字符串。每次调用它时,都产生一个不同的路径名,最多调用次数是TMP_MAX(定义在<stdio.h>中)。

  • 若ptr是NULL,则所产生的路径名存放在一个静态区中,指向该静态区的指针作为函数值返回。

  • 若ptr不是NULL,则它应该是指向长度至少是L_tmpnam个字符的数组(常量L_tmpnam定义在头文件<stdio.h>中)。

使用tmpnamtmpfile(备注:书上tmpfile的地方写的是tempnam,估计是写错了,这本书好多小错误啊,翻译和审核都不太认真)函数的缺点:在返回唯一的路径名和用该名字创建文件之间存在一个时间窗口,在这个时间窗口中,另一进程可以用相同的名字创建文件。

我们可以使用以下两个函数来解决这个问题:

#include <stdlib.h>

char* mkdtemp(char* template);
// 成功返回指向目录名的指针,出错返回NULL
int mkstemp(char* template);
// 成功返回文件描述符,出错返回-1
  • mkdtemp函数创建了一个目录,该目录有一个唯一的名字;
  • mkstemp函数创建了一个文件,该文件有一个唯一的名字。

名字是通过template字符串进行选择的。这个字符串是后6位设置为XXXXXX的路径名。函数将这些占位符替换成不同的字符来构建一个唯一的路径名。如果成功的话,这两个函数将修改template字符串反映临时文件的名字。

使用示例:

#include "apue.h"
#include <errno.h>

void make_temp(char* template);

int main() {
    char good_template[] = "/tmp/dirXXXXXX";
    char *bad_template = "/tmp/dirXXXXXX";
    printf("trying to create first temp file...\n");
    make_temp(good_template);
    printf("trying to create second temp file...\n");
    make_temp(bad_template);
    exit(0);
}

void make_temp(char* template) {
    int fd;
    struct stat sbuf;
    if ((fd = mkstemp(template)) < 0) {
        err_sys("can't create temporary file");
    }
    printf("temp name = %s\n", template);
    close(fd);
    if (stat(template, &sbuf) < 0) {
        if (errno == ENOENT) {
            printf("file doesn't exist\n");
        } else {
            err_sys("stat failed");
        }
    } else {
        printf("file exists\n");
        unlink(template);
    }
}

输出结果:

trying to create first temp file...
temp name = /tmp/dirKOBzQc
file exists
trying to create second temp file...
Segmentation fault (core dumped)

内存流

我们可以使用fmemopen函数进行内存流的创建:

#include <stdio.h>

FILE* fmemopen(void *restrict buf, size_t size, const char* restrict type);
// 成功返回流指针,失败返回NULL

type参数控制如何使用流:

image-20210428013218628

  • 无论何时以追加写方式打开内存流时,当前文件位置设为缓冲区中的第一个null字节。如果缓冲区中不存在null字节,则当前位置就设为缓冲区结尾的后一个字节。当流并不是以追加写方式打开时,当前位置设为缓冲区的开始位置。
  • 如果buf参数是一个null指针,打开流进行读或者写都没有任何意义。因为在这种情况下缓冲区是通过fmemopen进行分配的,没有办法找到缓冲区的地址,只写方式打开流意味着无法读取已写入的数据,同样,以读方式打开流意味着只能读取那些我们无法写入的缓冲区中的数据。
  • 任何时候需要增加流缓冲区中数据量以及调用fclose、fflush、fseek、fseeko以及fsetpos时都会在当前位置写入一个null字节。

使用示例:

#include "apue.h"

#define BSZ 48

int main() {
    FILE* fp;
    char buf[BSZ];
    memset(buf, 'a', BSZ-2);
    buf[BSZ-2] = '\0';
    buf[BSZ-1] = 'X';
    if ((fp = fmemopen(buf, BSZ, "w+")) == NULL) {
        err_sys("fmemopen failed");
    }
    printf("initial buffer contents: %s\n", buf);
    fprintf(fp, "hello, world");
    printf("before flush: %s\n", buf);
    fflush(fp);
    printf("after flush: %s\n", buf);
    printf("len of string in buf = %ld\n", (long)strlen(buf));

    memset(buf, 'b', BSZ-2);
    buf[BSZ-2] = '\0';
    buf[BSZ-1] = 'X';
    fprintf(fp, "hello, world!");
    fseek(fp, 0, SEEK_SET);
    printf("after fseek: %s\n", buf);
    printf("len of string in buf = %ld\n", (long)strlen(buf));

    memset(buf, 'c', BSZ-2);
    buf[BSZ-2] = '\0';
    buf[BSZ-1] = 'X';
    fprintf(fp, "hello, world");
    fclose(fp);
    printf("after fclose: %s\n", buf);
    printf("len of string in buf = %ld\n", (long)strlen(buf));
    return 0;
}

输出结果:

initial buffer contents: 
before flush: 
after flush: hello, world
len of string in buf = 12
after fseek: bbbbbbbbbbbbhello, world!
len of string in buf = 25
after fclose: hello, worldcccccccccccccccccccccccccccccccccc
len of string in buf = 46

还可以使用open_memstream和open_wmemstream函数来创建内存流:

#include <stdio.h>

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

#include <wchar.h>

FILE* openwmemstream(wchar_t** bufp, size_t *sizep);
// 以上两个函数,成功时返回流指针,出错时返回NULL

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

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

  • 创建的流只能写打开;
  • 不能指定自己的缓冲区,但可以分别通过bufp和sizep参数访问缓冲区地址和大小;
  • 关闭流后需要自行释放缓冲区;
  • 对流添加字节会增加缓冲区大小。

在缓冲区地址和大小的使用上必须遵循一些原则:

  1. 缓冲区地址和长度只有在调用fclose或fflush后才有效;
  2. 这些值只有在下一次流写入或调用fclose前才有效。

系统数据文件和信息

口令文件

口令文件是/etc/passwd,是一个ASCII文件。

为了阻止一个特定用户登录系统:

  • 可以将登录shell设置为/dev/null外
  • 还可以将登录shell设置为/dev/false。它简单地以不成功(非0)状态终止,该shell将此种终止状态判断为假。
  • 还可以将登录shell设置为/bin/true。它所做的一切是以成功(0)状态终止。
  • 某些系统提供nologin命令,它打印可定制的出错信息,然后以非0状态终止。

使用nobody用户名的一个目的是,使任何人都可登录至系统,

某些系统提供了vipw来编辑口令文件(需要管理员权限)。

可以通过以下两个函数来获取口令文件项:

#include <pwd.h>

struct passwd* getpwuid(uid_t uid);
struct passwd* getpwnam(const char* name);
// 成功返回指针,出错返回NULL

其中struct passwd的结构如下图所示:

image-20210428123359734

可以使用以下函数来查看整个口令文件:

#include <pwd.h>

struct passwd* getpwent(void);
// 成功返回指针,出错或者到达文件尾端,则返回NULL
void setpwent(void);
void endpwent(void);
  • getpwent函数返回口令文件中的下一个记录项。
  • setpwent将getpwent的读写地址指向密码文件开头。
  • endpwent关闭这些文件。

getpwnam函数的一种实现:

struct passwd* getpwnam(const char* name) {
    struct passwd *ptr;
    setpwent();
    while ((ptr = getpwent()) != NULL) {
        if (strcmp(name, ptr->pw_name) == 0) {
            break;
        }
    }
    endpwent();
    return ptr;
}

阴影口令

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

可以使用以下函数访问阴影口令文件:

#include <shadow.h>

struct spwd* getspwnam(const char* name);
struct spwd* getspent(void);
// 成功返回指针,出错返回NULL
void setspent(void);
void endspent(void);

其中struct spwd的结构如下图所示:

image-20210428123720350

组文件

可以使用以下两个函数来查看组名或数值组ID:

#include <grp.h>

struct group* getgrgid(gid_t gid);
struct group* getgrnam(const char* name);
// 成功返回指针,出错或者到达文件尾端,则返回NULL

其中struct group的结构如下图所示:

image-20210428124014848

可以使用以下几个函数来搜索整个组文件:

#include <grp.h>

struct group* getgrent(void);
// 成功返回指针,出错或者到达文件尾端,返回NULL
void setgrent(void);
void endgrent(void);

附属组ID

可以通过以下函数来获取和设置附属组ID:

#include <unistd.h>

int getgroups(int gidsetsize, git_t grouplist[]);
// 成功返回附属组ID数量,出错返回-1

#include <grp.h>    // in linux

int setgroups(int ngroups, const git_t grouplist[]);
int initgroups(const char* username, gid_t basegid);
// 成功返回0,出错返回-1
  • getgroups将进程所属用户的各附属组ID填写到数组grouplist中,填写入该数组的附属组ID数最多为gidsetsize个。实际填写到数组中的附属组ID数由函数返回。若gidsetsize为0,则函数只返回附属组ID数,而对数组grouplist则不做修改。

  • setgroups可由超级用户调用以便为调用进程设置附属组ID表。grouplist是组ID数组,而ngroups说明了数组中的元素数。ngroups的值不能大于NGROUPS_MAX。

  • initgroups读整个组文件(用数getgrent、setgrent和endgrent),然后对username确定其组的成员关系。然后,它调用setgroups,以便为该用户初始化附属组ID表。除了在组文件中找到 username所在的所有组,initgroups也在附属组ID表中包括了basegid。basegid是username在口令文件中的组ID。

其他数据文件

image-20210428133409728

登录账户记录

记录所用的结构体:

struct utmp {
    char ut_line[8];    // tty line: "ttyh0", "ttyp0", ...
    char ut_name[8];    // login name
    long ut_time;       // seconds since Epoch
};

登录时,login程序填写此类型结构,将其写入到utmp文件和wtmp文件中。

注销时,init进程将utmp文件中相应的记录擦除(每个字节都填以null字节),并将一个新记录添写到wtmp文件中。

在wtmp文件的注销记录中,ut_name字段清除为0。

在系统再启动时,以及更改系统时间和日期的前后,都在wtmp文件中追加写特殊的记录项。

who(1)程序读取utmp文件,并以可读格式打印其内容。

last(1)命令,它读wtmp文件并打印所选择的记录。

系统标识

可以使用uname函数来查看与操作系统相关信息:

#include <sys/utsname.h>

int uname(struct utsname* name);
// 成功返回非负值,出错返回-1

struct utsname的结构:

struct utsname {
    char sysname[];     // name of the OS
    char nodename[];    // name of this node
    char release[];     // current release of OS
    char version[];     // current version of this release
    char machine[];     // name of hardware type
};

可以使用uname(1)来打印utsname中的信息。

可以使用gethostname函数来查看主机名:

#include <unistd.h>

int gethostname(char* name, int namelen);
// 成功返回0,出错返回-1

可以通过hostname(1)命令来获取和设置主机名。

时间和日期例程

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

#include <time.h>

time_t time(time_t *calptr);
// 成功返回时间值,出错返回-1

还可以通过clock_gettime函数来获取指定时钟的时间:

#include <sys/time.h>

int clock_gettime(clockid_t clock_id, struct timespec *tsp);
// 成功返回0,出错返回-1

其中clock_id的标准值:

image-20210428134858155

当时钟ID设置为CLOCK_REALTIME时,clock_gettime函数提供了与time函数类似的功能,不过在系统支持高精度时间值的情况下,clock_gettime可能比time函数得到更高精度的时间值。

可以使用clock_getres函数来调整时钟精度:

#include <sys/time.h>

int clock_getres(clockid_t clock_id, struct timespec *tsp);
// 成功返回0,出错返回-1

clock_getres函数把参数tsp指向的timespec结构初始化为与clock_id参数对应的时钟精度。例如,如果精度为1毫秒,则tv_sec字段就是0,tv_nsec字段就是1 000 000。

我们可以使用clock_settime来给特定的时钟设定时间:

#include <sys/time.h>

int clock_settime(clockid_t clock_id, const struct timespec *tsp);
// 成功返回0,出错返回-1

SUSv4指定gettimeofday函数现在已弃用。然而,一些程序仍然使用这个函数,因为与time函数相比,gettimeofday提供了更高的精度(可到微秒级)。

#include <sys/time.h>

int gettimeofday(struct timeval* restrict tp, void* restrict tzp); // tzp的唯一合法值是NULL
// 总是返回0

可以使用localtime和gmtime将日历时间转换成分解的时间:

#include <time.h>

struct tm *gmtime(const time_t *calptr);
struct tm *localtime(const time_t *calptr);
// 成功返沪指向分解的tm结构的指针,出错返回NULL

其中struct tm的结构为:

struct tm { 		/* a broken-down time */
    int tm_sec; 	/* seconds after the minute: [0 - 60] */
    int tm_min;		/* minutes after the hour: [0 - 59] */
    int tm_hour;	/* hours after midnight: [0 - 23] */
    int tm_mday;	/* day of the month: [1 - 31] */
    int tm_mon;		/* months since January: [0 - 11] */
    int tm_year;	/* years since 1900 */
    int tm_wday;	/* days since Sunday: [0 - 6] */
    int tm_yday;	/* days since January 1: [0 - 365] */
    int tm_isdst;	/* daylight saving time flag: <0, 0, >0 */
};

可以通过mktime函数,以本地时间的年、月、日等作为参数,将其变换成time_t值。

#include <time.h>

time_t mktime(struct tm *tmptr);
// 返回值:若成功,返回日历时间;若出错,返回-1

可以使用strftime函数来格式化输出时间:

#include <time.h>

size_t strftime(char *restrict buf, size_t maxsize, const char *restrict format, const struct tm *restrict tmptr);
size_t strftime_l(char *restrict buf, size_t maxsize, const char *restrict format, const struct tm *restrict tmptr, locale_t locale);
// 若buf空间足够大则返回存入数组的字符数,否则返回0

strftime_l允许调用者将区域指定为参数,除此之外,strftime和strftime_l函数是相同的。

tmptr参数是要格式化的时间值,由一个指向分解时间值tm结构的指针说明。格式化结果存放在一个长度为maxsize个字符的buf数组中,如果buf长度足以存放格式化结果及一个null终止符,则该函数返回在buf中存放的字符数(不包括null终止符);否则该函数返回0。

format参数控制时间值的格式。

image-20210428142730331

一个使用示例:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main() {
    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);
}

输出结果:

buffer length 16 is too small
time and date: 02:28:54 PM, Wed Apr 28, 2021

strptime函数是strftime的逆向版本,把字符串时间转换成分解时间。

#include <time.h>

char *strptime(const char *restrict buf, const char *restrict format, struct tm *restrict tmptr);
// 返回值:指向上次解析的字符的下一个字符的指针;否则,返回NULL

格式说明符:

image-20210428143049136

进程环境

C程序总是从main函数开始执行。main函数的原型是:

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

其中,argc是命令行参数的数目,argv是指向参数的各个指针所构成的数组。

当内核通过exec函数执行C程序时,在调用main前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址(这是由连接编辑器设置的,而连接编辑器则由C编译器调用)。启动例程从内核取得命令行参数和环境变量值,为调用main函数做好准备。

进程终止

有8种方式使进程终止(termination)。

其中5种为正常终止:

  1. 从main返回;
  2. 调用exit
  3. 调用_exit_Exit
  4. 最后一个线程从其启动例程返回;
  5. 从最后一个线程调用pthread_exit

3种为异常终止:

  1. 调用abort
  2. 接到一个信号;
  3. 最后一个线程对取消请求做出响应。

可以使用exit函数来退出程序:

#include <stdlib.h>

void exit(int status);
void _Exit(int status);

#include <unistd.h>

void _exit(int status);

其中_exit_Exit立即进入内核,exit则先执行一些清理处理,然后返回内核。

传入的status参数被称为终止状态(在shell中可以通过echo $?命令来查看,没记错的话,状态的最大值好像是255)。

main函数返回一个整型值与用该值调用exit是等价的。

可以通过atexit函数来注册终止处理程序

#include <stdlib.h>

int atexit(void (*func)(void));
// 成功返回0,失败返回1

一个进程最多可以注册32个终止处理程序,这些函数将由exit自动调用。exit调用这些函数的顺序与它们注册的顺序相反。同一函数如若登记多次,也会被调用多次。

根据ISO C和POSIX.1,exit首先调用各终止处理程序,然后关闭(通过fclose)所有打开流。POSIX.1扩展了ISO C标准,它说明,如若程序调用exec函数族中的任一函数,则将清除所有已安装的终止处理程序。

atexit函数的使用示例:

#include "apue.h"

static void my_exit1(void);
static void my_exit2(void);

int main() {
    if (atexit(my_exit2) != 0) {
        err_sys("can't register my_exit2");
    }
    if (atexit(my_exit1) != 0) {
        err_sys("can't unregister my_exit1");
    }
    if (atexit(my_exit1) != 0) {
        err_sys("can't unregister my_exit1");
    }
    printf("main is done\n");
    return 0;
}

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

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

输出结果:

main is done
first exit handler
first exit handler
second exit handler

命令行参数

ISO C和POSIX.1都要求argv[argc]是一个空指针。

环境表

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

extern char **environ;

我们称environ为环境指针(environment pointer),指针数组为环境表,其中各指针指向的字符串为环境字符串。

C程序的存储布局

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

  • 正文段。这是由CPU执行的机器指令部分。通常,正文段是可共享的,还是只读的。

  • 初始化数据段。通常将此段称为数据段,它包含了程序中需明确地赋初值的变量。

  • 未初始化数据段。通常将此段称为bss段,在程序开始执行之前,内核将此段中的数据初始化为0或空指针。

  • 栈。自动变量以及每次函数调用时所需保存的信息都存放在此段中。每次函数调用时,其返回地址以及调用者的环境信息(如某些机器寄存器的值)都存放在栈中。

    递归函数每次调用自身时,就用一个新的栈帧,因此一次函数调用实例中的变量集不会影响另一次函数调用实例中的变量。

  • 堆。通常在堆中进行动态存储分配。由于历史上形成的惯例,堆位于未初始化数据段和栈之间。

典型的存储空间布局:

image-20210428200025392

可以通过size(1)命令来查看各个段的大小。

共享库

共享库使得可执行文件中不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种库例程的一个副本。

程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相链接。

这减少了每个可执行文件的长度,但增加了一些运行时间开销。这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。

共享库的另一个优点是可以用库函数的新版本代替老版本而无需对使用该库的程序重新连接编辑(假定参数的数目和类型都没有发生改变)。

存储空间分配

可以使用malloc、calloc、realloc函数来动态分配内存:

#include <stdlib.h>

void* malloc(size_t size);
void* calloc(size_t nobj, size_t size);
void* realloc(void* ptr, size_t newsize);
// 成功返回非空指针,出错返回NULL

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

如果ptr为NULL,那么realloc的功能和malloc相同。

可以使用free函数来释放动态分配的内存:

#include <stdlib.h>

void free(void* ptr);

环境变量

环境字符串的格式是:

name=value

可以使用getenv函数来获取环境变量值:

#include <stdlib.h>

char* getenv(const char* name);
// 找到则返回与name相关联的value的指针,没找到返回NULL

可以使用putenv, setenv, unsetenv来设置环境变量:

#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已经存在,则覆盖原来的定义。
  • setenv将name设置为value。若rewrite非0,则已存在时会进行覆盖;否则不删除其现有定义,也不报错。
  • unsetenv删除name的定义。即使不存在这种定义也不算出错。

putenv和setenv之间的差别:

  • setenv必须分配存储空间,以便依据其参数创建name=value字符串。
  • putenv可以自由地将传递给它的参数字符串直接放到环境中。

因此,将存放在栈中的字符串作为参数传递给putenv就会发生错误,其原因是,从当前函数返回时,其栈帧占用的存储区可能将被重用。

setjmp和longjmp

#include <setjmp.h>

int setjmp(jmp_buf env);
// 直接调用返回0,从longjmp中返回,返回非0
void longjmp(jmp_buf env, int val);

一个使用示例:

#include "apue.h"
#include <setjmp.h>

static void f1(int, int, int, int);
static void f2(void);

static jmp_buf jmpbuffer;
static int globalval;

int main() {
    int autoval;
    register int regival;
    volatile int volval;
    static int staval;
    globalval = 1, autoval = 2, regival = 3, volval = 4, staval = 5;
    if (setjmp(jmpbuffer) != 0) {
        printf("after longjmp:\n");
        printf("globalval = %d, autoval = %d, regival = %d, volval = %d, staval = %d\n", globalval, autoval, regival, volval, staval);
        exit(0);
    }
    globalval = 95, autoval = 96, regival = 97, volval = 98, staval = 99;
    f1(autoval, regival, volval, staval);
    return 0;
}

static void f1(int i, int j, int k, int l) {
    printf("in f1():\n");
    printf("globalval = %d, autoval = %d, regival = %d, volval = %d, staval = %d\n", globalval, i, j, k, l);
    f2();
}

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

输出结果:

in f1():
globalval = 95, autoval = 96, regival = 97, volval = 98, staval = 99
after longjmp:
globalval = 95, autoval = 96, regival = 3, volval = 98, staval = 99

变量在调用longjmp后值是否回滚是不确定的

如果你有一个自动变量,而又不想使其值回滚,则可定义其为具有volatile属性。

声明为全局变量或静态变量的值在执行longjmp时保持不变。

getrlimit和setrlimit

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

#include <sys/resource.h>

int getrlimit(int resource, struct rlimit* rlptr);
int setrlimit(int resource, struct rlimit* rlptr);
// 成功返回0,出错返回非0

其中struct rlimit的结构如下:

struct rlimit {
    rlim_t rlim_cur; /* soft limit: current limit */
    rlim_t rlim_max; /* hard limit: maximum value for rlim_cur */
};

在更改资源限制时,须遵循下列3条规则:

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

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

resource参数的取值可参考APUE第三版P176-177。

进程控制

进程标识

每个进程都有一个非负整型表示的唯一进程ID。

虽然是唯一的,但是进程ID是可复用的。当一个进程终止后,其进程ID就成为复用的候选者。

系统中有一些专用进程,具体细节随实现而不同:

  • ID为0的进程通常是调度进程,常常被称为交换进程(swapper)。该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。

  • 进程ID为1的进程通常是init进程,在自举过程结束时由内核调用。此进程负责在自举内核后启动一个UNIX系统。init进程决不会终止。它是一个普通的用户进程(与交换进程不同,它不是内核中的系统进程),但是它以超级用户特权运行。

  • 在某些UNIX的虚拟存储器实现中,进程ID为2的进程是页守护进程(page daemon),此进程负责支持虚拟存储器系统的分页操作。

可以通过以下函数来获取进程的一些标识符:

#include <unistd.h>

pid_t getpid(void);     // 返回调用进程的进程ID
pid_t getppid(void);    // 返回调用进程的父进程ID
uid_t getuid(void);     // 返回调用进程的实际用户ID
uid_t geteuid(void);    // 返回调用进程的有效用户ID
git_t getgid(void);     // 返回调用进程的实际组ID
gid_t getegid(void);    // 返回调用进程的有效组ID

// 这些函数都没有出错返回

fork

一个现有的进程可以调用fork函数来创建一个新的线程:

#include <unistd.h>

pid_t fork(void);
// 子进程返回0,父进程返回子进程ID,出错返回-1

子进程是父进程的副本,获得父进程数据空间、堆和栈的副本。

父进程和子进程共享正文段。

由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全副本。作为替代,使用了写时复制(Copy-On-Write,COW)技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。

fork函数的简单演示:

#include "apue.h"

int globvar = 6;
char buf[] = "a write to stdout\n";

int main() {
    int var;
    pid_t pid;
    var = 88;
    if (write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) - 1) {
        err_sys("write error!");
    }
    printf("before fork\n"); // don't flush stdout
    if ((pid = fork()) < 0) {
        err_sys("fork error!");
    } else if (pid == 0) { // child process
        globvar++;
        var++;
    } else { // parent process
        sleep(2);
    }
    printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar, var);
    return 0;
}

直接输出结果:

a write to stdout
before fork
pid = 25767, glob = 7, var = 89
pid = 25766, glob = 6, var = 88

重定向到某个文件后的输出结果:

a write to stdout
before fork
pid = 25790, glob = 7, var = 89
before fork
pid = 25789, glob = 6, var = 88

重定向会使得标准输出从“行缓冲”变为“全缓冲”,进而导致上述结果的不同。

fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。父进程和子进程每个相同的打开描述符共享一个文件表项,就好像执行了dup函数一样。

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

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

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

父进程和子进程之间的区别:

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

vfork

vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序。

vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中。

在子进程调用exec或exit之前,它在父进程的空间中运行

vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。

exit

进程有5种正常终止及3种异常终止方式。

5种正常终止方式:

  1. 在main函数内执行return语句,这等效于调用exit。

  2. 调用exit函数。其操作包括调用各终止处理程序(终止处理程序在调用atexit函数时登记),然后关闭所有标准I/O流等。

  3. 调用_exit_Exit函数。

  4. 进程的最后一个线程在其启动例程中执行return语句。但是,该线程的返回值不用作进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回。

  5. 进程的最后一个线程调用pthread_exit函数。进程终止状态总是0,与传送给pthread_exit的参数无关。

3种异常终止方式:

  1. 调用abort。它产生SIGABRT信号。
  2. 当进程接收到某些信号时。信号可由进程自身(如调用abort函数)、其他进程或内核产生。
  3. 最后一个线程对“取消”(cancellation)请求作出响应。

对于父进程已经终止的所有进程,它们的父进程都改变为init进程。这个过程被称为init进程收养

一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为僵死进程(zombie)。ps(1)命令将僵死进程的状态打印为Z。

wait和waitpid

当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHLD 信号。因为子进程终止是个异步事件(这可以在父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步通知。

调用wait和waitpid的可能影响:

  • 如果其所有子进程都还在运行,则阻塞。

  • 如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。

  • 如果它没有任何子进程,则立即出错返回。

#include <sys/wait.h>

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
// 成功返回进程ID,出错返回0或-1

statloc是一个整型指针。如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可将该参数指定为空指针。

可以通过以下的宏对statloc进行测试(其中的status=*statloc):

waitpid函数中pid参数的作用:

  • pid == −1:等待任一子进程。此种情况下,waitpid与wait等效。

  • pid > 0:等待进程ID与pid相等的子进程。

  • pid == 0:等待组ID等于调用进程组ID的任一子进程。

  • pid < −1:等待组ID等于pid绝对值的任一子进程。

waitpid的options参数:

对于wait,其唯一的出错是调用进程没有子进程。但是对于waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都可能出错。

一个有趣的例子,通过fork两次,让父进程无需等待子进程终止,子进程无需处于僵死状态直到父进程终止。

#include "apue.h"
#include <sys/wait.h>

int main() {
    pid_t pid;
    if ((pid = fork()) < 0) {
        err_sys("fork error");
    } else if (pid == 0) {
        if ((pid = fork()) < 0) {
            err_sys("fork error");
        } else if (pid > 0) {
            exit(0);
        }
        sleep(2);
        // when cur process's parent called exit(0),
        // cur process will be adopted by the init process
        printf("second child, parent pid = %ld\n", (long)getppid());
        exit(0);
    }
    if (waitpid(pid, NULL, 0) != pid) {
        err_sys("waitpid error");
    }
    exit(0);
}

waitid

Single UNIX Specification包括了另一个取得进程终止状态的函数waitid,此函数类似于waitpid,但提供了更多的灵活性。

#include <sys/wait.h>

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
// 成功返回0,出错返回-1

该函数支持的idtype和options如下:

infop参数是指向siginfo结构的指针。该结构包含了造成子进程状态改变有关信号的详细信息。

wait3和wait4

wait3和wait4是从BSD分支沿袭下来的,他们提供的功能比POSIX.1函数wait, waitpid, waitid要多一个。

#include <sys/types.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>

pid_t wait3(int *statloc, int options, struct rusage* rusage);
pid_t wait4(pid_t pid, int *statloc, int options, struct rusage* rusage);
// 成功返回进程ID,出错返回-1

exec

当进程调用exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。

调用exec并不创建新进程,exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。

#include <unistd.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* const argv[], char* const envp[]);
int execlp(const char* pathname, const char* arg0, ... /* (char*)0 */);
int execvp(const char* pathname, char* const argv[]);
int fexecve(int fd, char* const argv[], char* const envp[]);
// 成功不返回,出错返回-1

带有p的函数:

  • 如果filename中包含/,则就将其视为路径名;

  • 否则就按PATH环境变量,在它所指定的各目录中搜寻可执行文件。

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

l代表list,v代表vector。

以e结尾的函数可以传递一个指向环境字符串指针数组的指针。其他4个函数则使用调用进程中的environ变量为新程序复制现有的环境。

在exec前后实际用户ID和实际组ID保持不变,而有效ID是否改变则取决于所执行程序文件的设置用户ID位和设置组ID位是否设置。如果新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID;否则有效用户ID不变。对组ID的处理方式与此相同。

更改用户ID和更改组ID

可以通过setuid和setgid来设置用户ID和组ID:

#include <unistd.h>

int setuid(uid_t uid);
int setgid(gid_t gid);
// 成功返回0,出错返回-1

其中的更改规则可参考下图(还包括exec函数的情况):

可以通过setreuid和setregid来交换实际用户ID和有效用户ID的值:

#include <unistd.h>

int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);
// 成功返回0,失败返回-1

可以通过seteuid和setegid来设置有效ID:

#include <unistd.h>

int seteuid(uid_t uid);
int setegid(gid_t gid);
// 成功返回0,出错返回-1

解释器文件

解释器文件(interpreter file)是文本文件,其起始行的形式是:

#! pathname [ optional-argument ]

个人理解:如果指定了起始行的话,就会在原来的argv数组前插入起始行的参数。

system

可以使用system函数在程序中执行一个命令行命令:

#include <stdlib.h>

int system(const char* cmdstring);

如果cmdstring是一个空指针,则仅当命令处理程序可用时,system返回非0值。

system在其实现中调用了fork、exec和waitpid,因此有3种返回值。

  1. fork失败或者waitpid返回除EINTR之外的出错,则system返回−1,并且设置errno。

  2. exec失败(表示不能执行shell),则其返回值如同shell执行了exit(127)一样。

  3. 如果所有3个函数(fork、exec和waitpid)都成功,那么system的返回值是shell的终止状态。

设置用户ID或设置组ID程序决不应调用system函数。

进程会计

超级用户执行一个带路径名参数的accton命令启用会计处理。会计记录写到指定的文件中,在FreeBSD和Mac OS X中,该文件通常是/var/account/acct;在Linux中,该文件是/var/account/pacct;在Solaris中,该文件是/var/adm/pacct。执行不带任何参数的accton命令则停止会计处理。

典型的会计记录包含总量较小的二进制数据,一般包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。

会计记录所需的各个数据(各CPU时间、传输的字符数等)都由内核保存在进程表中,并在一个新进程被创建时初始化(如fork之后在子进程中)。进程终止时写一个会计记录。

会计记录的注意事项:

  1. 我们不能获取永远不终止的进程的会计记录。像init这样的进程在系统生命周期中一直在运行,并不产生会计记录。这也同样适合于内核守护进程,它们通常不会终止。
  2. 在会计文件中记录的顺序对应于进程终止的顺序,而不是它们启动的顺序。
  3. 会计记录对应于进程而不是程序。

用户标识

可以使用getlogin函数来获取用户登录名:

#include <unistd.h>

char* getlogin(void);
// 成功返回指向登录名字符串的指针,出错返回NULL

如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败。通常称这些进程为守护进程(daemon)。

给出了登录名,就可用getpwnam在口令文件中查找用户的相应记录,从而确定其登录shell等。

进程调度

NZERO是系统默认的友好值。

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

#include <unistd.h>

int nice(int incr);
// 成功返回新的友好值,出错返回-1

ncr参数被增加到调用进程的nice值上。如果incr太大,系统直接把它降到最大合法值。如果incr太小,系统会无声息地把它提高到最小合法值。

可以使用getpriority函数来获取友好值:

#include <sys/resource.h>

int getpriority(int which, id_t who);
// 成功返回-NZERO~NZEROR-1之间的友好值,出错返回-1

which参数控制who参数是如何解释的:

  • PRIO_PROCESS:进程;
  • PRIO_PGRP:进程组;
  • PRIO_USER:用户ID。

如果who参数为0,表示调用进程、进程组或者用户。

当which设为PRIO_USER并且who为0时,使用调用进程的实际用户ID。

如果which参数作用于多个进程,则返回所有作用进程中优先级最高的(最小的nice值)。

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

#include <sys/resource.h>

int setpriority(int which, id_t who, int value);
// 成功返回0,出错返回-1

进程时间

任意进程都可以通过times函数来获取进程时间:

#include <sys/times.h>

clock_t times(struct tms* buf);
// 成功返回流逝的墙上时钟时间,出错返回-1

其中struct tms的结构为:

struct tms {
    clock_t tms_utime;  // user CPU time
    clock_t tms_stime;  // system CPU time
    clock_t tms_cutime; // user CPU time, terminated children
    clock_t tms_cstime; // system CPU time, terminated children
};

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

进程关系

终端登录

Unix系统传统的用户身份验证

当系统自举时,内核创建init进程。init进程使系统进入多用户模式。init进程读取文件/etc/ttys,对每一个允许登录的终端设备,init调用一次fork,它所生成的子进程则exec getty程序。

getty对终端设备调用open函数,以读、写方式将终端打开。

当用户键入了用户名后,getty的工作就完成了。然后它以类似于下列的方式调用login程序:

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

现代UNIX系统的多身份验证

FreeBSD、Linux、Mac OS X以及Solaris都支持被称为 PAM(Pluggable Authentication Modules,可插入的身份验证模块)的更加灵活的方案。PAM 允许管理人员配置使用何种身份验证方法来访问那些使用PAM库编写的服务。

如果用户正确登录,login会完成如下工作:

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

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

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

  • 调用setgid及initgroups设置进程的组ID。

  • 用login得到的所有信息初始化环境:起始目录(HOME)、shell(SHELL)、用户名(USER和LOGNAME)以及一个系统默认路径(PATH)。

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

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

    其中argv[0]的第一个字符负号是一个标志,表示该shell被作为登录shell调用。

然后登录shell读取启动文件(如.bashrc等),当执行完启动文件后,用户最后得到 shell提示符,并能键入命令。

网络登录

在网络登录情况下,login仅仅是一种可用的服务,这与其他网络服务(如FTP或SMTP)的性质相同。

为使同一个软件既能处理终端登录,又能处理网络登录,系统使用了一种称为伪终端(pseudo terminal)的软件驱动程序,它仿真串行终端的运行行为,并将终端操作映射为网络操作,反之亦然。

BSD网络登录:

作为系统启动的一部分,init调用一个shell,使其执行shell脚本/etc/rc。由此shell脚本启动一个守护进程inetd。一旦此shell脚本终止,inetd的父进程就变成init。inetd等待TCP/IP连接请求到达主机,而当一个连接请求到达时,它执行一次fork,然后生成的子进程exec适当的程序。

inetd有时被称为因特网超级服务器,它等待大多数网络连接。

其他系统的登录也大致相同。

进程组

每个进程除了有一进程ID之外,还属于一个进程组。

进程组是一个或多个进程的集合。通常,它们是在同一作业中结合起来的,同一进程组中的各进程接收来自同一终端的各种信号。每个进程组有一个唯一的进程组ID。

可以使用getpgrp函数来获得调用进程的进程组ID:

#include <unistd.h>

pid_t getpgrp(void);
// 返回调用进程的进程组ID
  • 每个进程组有一个组长进程。组长进程的进程组ID等于其进程ID。

  • 进程组组长可以创建一个进程组、创建该组中的进程,然后终止。

  • 只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。

  • 从进程组创建开始到其中最后一个进程离开为止的时间区间称为进程组的生命期。

  • 某个进程组中的最后一个进程可以终止,也可以转移到另一个进程组。

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

#include <unistd.h>

int setpgid(pid_t pid, pid_t pgid);
// 成功返回0,出错返回-1

setpgid函数将pid进程的进程组ID设置为pgid。

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

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

会话

会话(session)是一个或多个进程组的集合。

通常是由shell的管道将几个进程编成一组的。

进程可以调用setsid函数来创建一个新会话:

#include <unistd.h>

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

如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新会话:

  1. 该进程变成新会话的会话首进程(session leader,会话首进程是创建该会话的进程)。

  2. 该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID。

  3. 该进程没有控制终端。如果在调用setsid之前该进程有一个控制终端,那么这种联系也被切断。

如果该调用进程已经是一个进程组的组长,则此函数返回出错。

可以调用getsid函数来获取会话首进程的进程组ID:

#include <unistd.h>

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

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

出于安全方面的考虑,一些实现有如下限制:如若pid并不属于调用者所在的会话,那么调用进程就不能得到该会话首进程的进程组ID。

控制终端

会话和进程组的其他特性:

  • 一个会话可以有一个控制终端。通常是终端设备(终端登录)或伪终端设备(网络登录)。

  • 建立与控制终端连接的会话首进程被称为控制进程

  • 一个会话中的几个进程组可被分成一个前台进程组以及一个或多个后台进程组

  • 如果一个会话有一个控制终端,则它有一个前台进程组,其他进程组为后台进程组。

  • 无论何时键入终端的中断键(常常是Delete或Ctrl+C),都会将中断信号发送至前台进程组的所有进程。

  • 无论何时键入终端的退出键(常常是Ctrl+\),都会将退出信号发送至前台进程组的所有进程。

  • 如果终端接口检测到调制解调器(或网络)已经断开连接,则将挂断信号发送至控制进程。

tcgetpgrp, tcsetpgrp, tcgetsid

可以通过tcgetpgrp和tcsetpgrp来控制前台进程组:

#include <unistd.h>

pid_t tcgetpgrp(int fd);
// 成功返回前台进程组ID,出错返回-1
int tcsetpgrp(int fd, pid_t pgrpid);
// 成功返回0,出错返回-1

如果进程有一个控制终端,则该进程可以调用tcsetpgrp将前台进程组ID设置为pgrpid。

  • pgrpid值应当是在同一会话中的一个进程组的ID。
  • fd必须引用该会话的控制终端。

可以通过tcgetsid函数来获得会话首进程的进程组ID:

#include <termios.h>

pid_t tcgetsid(int fd);
// 成功返回会话首进程进程组ID,出错返回-1

作业控制

作业控制要求以下3种形式的支持。

  1. 支持作业控制的shell。

  2. 内核中的终端驱动程序必须支持作业控制。

  3. 内核必须提供对某些作业控制信号的支持。

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

  • 中断字符(一般采用Delete或Ctrl+C)产生SIGINT;

  • 退出字符(一般采用Ctrl+\)产生SIGQUIT;

  • 挂起字符(一般采用Ctrl+Z)产生SIGTSTP。

只有前台作业接收终端输入。如果后台作业试图读终端,这并不是一个错误,但是终端驱动程序将检测这种情况,并且向后台作业发送一个特定信号SIGTTIN。该信号通常会停止此后台作业,而shell则向有关用户发出这种情况的通知,然后用户就可用shell命令将此作业转为前台作业运行,于是它就可读终端。

我们可以通过stty(1)命令来控制后台作业是否允许输出到控制终端。当被禁止时,会向作业发送SIGTTOU信号,使其阻塞(类似SIGTTIN)。

shell执行程序

这一部分我在自己电脑上测试的结果和书上的结果不太一样,自己也没搞得很明白,暂时不记录笔记。

孤儿进程组

POSIX.1将孤儿进程组(orphaned process group)定义为:该组中每个成员的父进程要么是该组的一个成员,要么不是该组所属会话的成员。

一个进程组不是孤儿进程组的条件是:该组中有一个进程,其父进程在属于同一会话的另一个组中。

如果进程组不是孤儿进程组,那么在属于同一会话的另一个组中的父进程就有机会重新启动该组中停止的进程。

一个示例:

#include "apue.h"
#include <errno.h>

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, pgrp = %ld, tpgrp = %ld\n", name, (long) getpid(), (long) getppid(),
           (long) getpgrp(), (long) tcgetpgrp(STDIN_FILENO));
    fflush(stdout);
}

int main() {
    char c;
    pid_t pid;
    pr_ids("parent");
    if ((pid = fork()) < 0) {
        err_sys("fork error");
    } else if (pid > 0) {
        sleep(5);
    } else {
        pr_ids("child");
        signal(SIGHUP, sig_hup);
        kill(getpid(), SIGTSTP);
        pr_ids("child");
        if (read(STDIN_FILENO, &c, 1) != 1) {
            printf("read error %d on controlling TTY\n", errno);
        }
        exit(0);
    }
}

我使用clion运行时,结果和书上类似,但是手动通过gcc编译,然后在wsl中运行的结果和书上不太一样,目前没搞清楚怎么回事。

FreeBSD实现

每个会话都分配一个session结构:

  • s_count是该会话中的进程组数。当此计数器减至0时,则可释放此结构。

  • s_leader是指向会话首进程proc结构的指针。

  • s_ttyvp是指向控制终端vnode结构的指针。

  • s_ttyp是指向控制终端tty结构的指针。

  • s_sid是会话ID。

在调用setsid时,在内核中分配一个新的session结构:

  • s_count设置为1。
  • s_leader设置为调用进程proc结构的指针。
  • s_sid设置为进程ID。
  • 因为新会话没有控制终端,所以s_ttyvp和s_ttyp设置为空指针。

每个终端设备和每个伪终端设备均在内核中分配tty结构:

  • t_session指向将此终端作为控制终端的session结构。终端在失去载波信号时使用此指针将挂起信号发送给会话首进程。

  • t_pgrp指向前台进程组的pgrp结构。终端驱动程序用此字段将信号发送给前台进程组。由输入特殊字符(中断、退出和挂起)而产生的3个信号被发送至前台进程组。

  • t_termios是包含所有这些特殊字符和与该终端有关信息(如波特率、回显打开或关闭等)的结构。

  • t_winsize是包含终端窗口当前大小的winsize型结构。当终端窗口大小改变时,信号SIGWINCH被发送至前台进程组。

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

pgrp结构包含一个特定进程组的信息:

  • pg_id是进程组ID。

  • pg_session指向此进程组所属会话的session结构。

  • pg_members是指向此进程组proc结构表的指针,该proc结构代表进程组的成员。proc结构中p_pglist结构是双向链表,指向该组中的下一个进程和上一个进程。直到遇到进程组中的最后一个进程,它的proc结构中p_pglist结构为空指针。

proc结构包含一个进程的所有信息:

  • p_pid包含进程ID。

  • p_pptr是指向父进程proc结构的指针。

  • p_pgrp指向本进程所属的进程组的pgrp结构的指针。

  • p_pglist是一个结构,其中包含两个指针,分别指向进程组中上一个和下一个进程。

信号

信号概念

信号是软件中断。每个信号都有一个名字,这些名字都以"SIG"开头。

在头文件<signal.h>中,信号名都被定义为正整数常量(信号编号)。不存在编号为0的信号。

产生信号的一些途径:

  • 按某些终端键会引发终端产生信号。

  • 硬件异常产生信号:除数为0、无效的内存引用等。这些条件通常由硬件检测到,并通知内核。然后内核为该条件发生时正在运行的进程产生适当的信号。

  • 进程调用kill(2)函数可将任意信号发送给另一个进程或进程组。权限要求:接收信号进程和发送信号进程的所有者必须相同,或发送信号进程的所有者必须是超级用户。

  • 在终端使用kill(1)命令将信号发送给其他进程。此命令是kill函数的接口

  • 当检测到某种软件条件已经发生,并应将其通知有关进程时也产生信号。例如 SIGURG(在网络连接上传来带外的数据)、SIGPIPE(在管道的读进程已终止后,一个进程写此管道)以及 SIGALRM(进程所设置的定时器已经超时)。

信号的处理方式:

  • 忽略信号。

  • 捕捉信号。

  • 执行系统默认动作。对大多数信号的系统默认动作是终止该进程。

SIGKILL和SIGSTOP信号不能被忽略和捕捉。

这两种信号不能被忽略的原因是:它们向内核和超级用户提供了使进程终止或停止的可靠方法。另外,如果忽略某些由硬件异常产生的信号(如非法内存引用或除以0),则进程的运行行为是未定义的。

信号的详细介绍请参考APUE第三版P252-P256。

signal

#include <signal.h>

void (*signal(int signo, void (*func)(int)))(int);
// 成功返回以前的信号处理程序,出错返回SIG_ERR

// 可以用typedef简化函数原型
typedef void (*Sigfunc)(int);
Sigfunc* signal(int, Sigfunc*);
  • signo参数是信号名。

  • func的值是常量SIG_IGN、常量SIG_DFL或信号处理函数的地址。

    • 如果指定SIG_IGN,则向内核表示忽略此信号。
    • 如果指定SIG_DFL,则表示接到此信号后的动作是系统默认动作。
    • 当指定函数地址时,则在信号发生时,调用该函数,我们称这种处理为捕捉该信号,称此函数为信号处理程序(signal handler)或信号捕捉函数(signal-catching function)。

一个简单的示例:

#include "apue.h"

static void sig_usr(int);

int main() {
    if (signal(SIGUSR1, sig_usr) == SIG_ERR) {
        err_sys("can't catch SIGUSR1");
    }
    if (signal(SIGUSR2, sig_usr) == SIG_ERR) {
        err_sys("can't catch SIGUSR2");
    }
    for (;;) {
        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);
    }
}

中断的系统调用

早期UNIX系统的一个特性是:如果进程在执行一个低速系统调用而阻塞期间捕捉到一个信号,则该系统调用就被中断不再继续执行。该系统调用返回出错,其errno设置为EINTR。

系统调用分成两类:低速系统调用和其他系统调用。低速系统调用是可能会使进程永远阻塞的一类系统调用。

为了帮助应用程序使其不必处理被中断的系统调用,4.2BSD引进了某些被中断系统调用的自动重启动。

自动重启动的系统调用包括:ioctl、read、readv、write、writev、wait 和waitpid。

前5个函数只有对低速设备进行操作时才会被信号中断。而wait和waitpid在捕捉到信号时总是被中断。

可重入函数

SUS说明了在信号处理程序中保证调用安全的函数。这些函数是可重入的并被称为是异步信号安全的(async-signal safe)。

不可重入的可能情况:

  • 已知它们使用静态数据结构;
  • 它们调用 malloc 或free;
  • 它们是标准I/O函数。

SIGCLD语义

在linux里,SIGCLD与SIGCHLD相同。

对于SIGCLD的早期处理方式:

  • 如果进程明确地将该信号的配置设置为SIG_IGN,则调用进程的子进程将不产生僵死进程。这与其默认动作(SIG_DFL)“忽略”(见图10-1)不同。子进程在终止时,将其状态丢弃。如果调用进程随后调用一个wait函数,那么它将阻塞直到所有子进程都终止,然后该wait会返回−1,并将其errno设置为ECHILD。(此信号的默认配置是忽略,但这不会使上述语义起作用。必须将其配置明确指定为SIG_IGN才可以。)

  • 如果将SIGCLD的配置设置为捕捉,则内核立即检查是否有子进程准备好被等待,如果有,则调用SIGCLD处理程序。

可靠信号术语和语义

  1. 当造成信号的事件发生时,为进程产生一个信号(或向一个进程发送一个信号)。
  2. 当一个信号产生时,内核通常在进程表中以某种形式设置一个标志。
  3. 当对信号采取了这种动作时,我们说向进程递送了一个信号。
  4. 在信号产生(generation)和递送(delivery)之间的时间间隔内,称信号是未决的(pending)。

kill和raise

kill函数将信号发送给进程或进程组,raise函数允许进程向自身发送信号。

#include <signal.h>

int kill(pid_t pid, int signo);
int raise(int signo);
// 成功返回0,出错返回-1

调用raise(signo)等同于调用kill(getpid(), signo)

kill的pid参数有4种不同的情况:

  • pid > 0:将信号发送给进程ID为pid的进程。

  • pid == 0:将信号发送给与发送进程属于同一进程组且发送进程具有发送权限的所有进程。

  • pid < 0:将信号发送给进程组ID等于pid绝对值,而且发送进程发送权限的所有进程。

  • pid == −1:将信号发送给发送进程有权限向它们发送信号的所有进程。

这里的术语“所有进程”不包括实现定义的系统进程集。对于大多数UNIX系统,系统进程集包括内核进程和init(pid为1)。

POSIX.1将信号编号0定义为空信号。如果signo参数是0,则kill仍执行正常的错误检查,但不发送信号。这常被用来确定一个特定进程是否仍然存在。如果向一个并不存在的进程发送空信号,则kill返回−1,errno被设置为ESRCH。

alarm和pause

alarm函数用来设置一个定时器,在将来的某个时刻定时器会超时,并产生SIGALRM信号。如果忽略或者不捕捉该信号的话,默认动作是终止调用alarm函数的进程。

#include <unistd.h>

unsigned int alarm(unsigned int seconds);
// 返回0或者剩余的闹钟时间

参数seconds的值是产生信号SIGALRM需要经过的时钟秒数。当这一时刻到达时,信号由内核产生,由于进程调度的延迟,所以进程得到控制从而能够处理该信号还需要一个时间间隔。

调用alarm(0)可以取消上次的闹钟时间,并将其余留时间作为返回值。

pause函数使调用进程挂起直至捕捉到一个信号。

#include <unistd.h>

int pause(void);
// 返回-1,并将errno设置为EINTR

信号集

POSIX.1定义数据类型sigset_t以包含一个信号集,并且定义了5个处理信号集的函数。

#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指向的信号集,使其包括所有信号。

  • sigaddset将一个信号添加到已有的信号集中

  • sigdelset从信号集中删除一个信号。

sigprocmask

一个进程的信号屏蔽字规定了当前阻塞而不能递送给该进程的信号集。调用函数sigprocmask可以检测或更改,或同时进行检测和更改进程的信号屏蔽字。

#include <signal.h>

int sigprocmask(int how, const sigset_t* restrict set, sigset_t* restrict oset);
// 成功返回0,出错返回-1
  • 若oset是非空指针,那么进程的当前信号屏蔽字通过oset返回。

  • 若set是一个非空指针,则参数how指示如何修改当前信号屏蔽字。

  • 如果set是个空指针,则不改变该进程的信号屏蔽字,how的值也无意义。

在调用sigprocmask后如果有任何未决的、不再阻塞的信号,则在sigprocmask返回前,至少将其中之一递送给该进程。

一个简单的示例:

#include "apue.h"
#include <errno.h>

void pr_mask(const char* str) { // print the signals
    sigset_t sigset;
    int errno_save;
    errno_save = errno;
    if (sigprocmask(0, NULL, &sigset) < 0) {
        err_ret("sigprocmask errno");
    } 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;
}

sigpending

sigpending函数返回一信号集。对于调用进程而言,其中的各信号是阻塞不能递送的,因而也一定是当前未决的。

#include <signal.h>

int sigpending(sigset_t* set);
// 成功返回0,出错返回-1

一个示例程序:

#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_SETMASK error");
    }
    printf("SIGQUIT unblocked\n");
    sleep(5);
    exit(0);
}

static void sig_quit(int signo) {
    printf("caught SIGQUIT\n");
    if (signal(SIGQUIT, SIG_DFL) == SIG_ERR) {
        err_sys("can't reset SIGQUIT");
    }
}

sigaction

sigaction函数用来检查或修改与指定信号相关联的处理动作。

#include <signal.h>

int sigaction(int signo, const struct sigaction* restrict act, struct sigaction* oact);
// 成功返回0,出错返回-1
  • signo是要检测或修改其具体动作的信号编号。
  • 若act指针非空,则要修改其动作。
  • 如果oact指针非空,则通过oact指针返回该信号的上一个动作。

其中struct sigaction的结构如下:

struct sigaction {
    void (*sa_handler)(int);    // 信号处理程序地址或SIG_IGN或SIG_DEL
    sigset_t sa_mask;           // 额外的阻塞信号
    int sa_flags;               // signal options
    void (*sa_sigaction)(int, siginfo_t*, void*);   // 替代的信号处理程序
};

如果sa_handler字段包含一个信号捕捉函数的地址(不是常量SIG_IGN或SIG_DFL),则sa_mask字段说明了一个信号集,在调用该信号捕捉函数之前,这一信号集要加到进程的信号屏蔽字中。仅当从信号捕捉函数返回时再将进程的信号屏蔽字恢复为原先值。

sa_flags字段指定对信号进行处理的各个选项。

sa_sigaction字段是一个替代的信号处理程序,在sigaction结构中使用了SA_SIGINFO标志时,使用该信号处理程序。对于sa_sigaction字段和sa_handler字段两者,实现可能使用同一存储区,所以应用只能一次使用这两个字段中的一个。

siginfo结构包含了信号产生原因的有关信息。该结构的大致样式如下所示。

struct siginfo {
    int si_signo;	/* signal number */
    int si_errno; 	/* if nonzero, errno value from <errno.h> */
    int si_code;	/* additional info (depends on signal) */
    pid_t si_pid;	/* sending process ID */
    uid_t si_uid;	/* sending process real user ID */
    void* si_addr;	/* address that caused the fault */
    int si_status; 			/* exit value or signal number */
    union sigval si_value; 	/* application-specific value */
    /* possibly other fields also */
};

sigsetjmp和siglongjmp

可以使用sigsetjmp和siglongjmp来在信号处理程序中进行非局部转移:

#include <setjmp.h>

int sigsetjmp(segjmp_buf env, int savemask);
// 直接调用返回0,从siglongjmp调用中返回非0
void siglongjmp(sigjmp_buf env, int val);
  • 调用sigsetjmp时,如果savemask非0,则sigsetjmp在env中保存进程的当前信号屏蔽字。

  • 调用siglongjmp时,如果带非0savemask的sigsetjmp调用已经保存了env,则siglongjmp从其中恢复保存的信号屏蔽字。

sigsuspend

使用sigsuspend可以在一个原子操作中先恢复信号屏蔽字,然后使进程休眠

#include <signal.h>

int sigsuspend(const sigset_t* sigmask);
// 返回-1,并将errno设置为EINTR

进程的信号屏蔽字设置为由sigmask指向的值。在捕捉到一个信号或发生了一个会终止该进程的信号之前,该进程被挂起。如果捕捉到一个信号而且从该信号处理程序返回,则sigsuspend返回,并且该进程的信号屏蔽字设置为调用sigsuspend之前的值。

一个示例程序:

#include "apue.h"

volatile sig_atomic_t quitflag;

static void sig_int(int signo) {
    if (signo == SIGINT) {
        printf("\ninterrupt\n");
    } else if (signo == SIGQUIT) {
        quitflag = 1;
    }
}

int main() {
    sigset_t newmask, oldmask, zeromask;
    if (signal(SIGINT, sig_int) == SIG_ERR) {
        err_sys("signal(SIGINT) error");
    }
    if (signal(SIGQUIT, sig_int) == SIG_ERR) {
        err_sys("signal(SIGQUIT) error");
    }
    sigemptyset(&zeromask);
    sigemptyset(&newmask);
    sigaddset(&newmask, SIGQUIT);
    if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) < 0) {
        err_sys("SIG_BLOCK error");
    }
    while (quitflag == 0) {
        sigsuspend(&zeromask);
    }
    quitflag = 0;
    printf("hello, world!");
    if (sigprocmask(SIG_BLOCK, &oldmask, NULL) < 0) {
        err_sys("SIG_SETMASK error");
    }
    exit(0);
}

abort

可以使用abort函数使程序异常终止:

#include <stdlib.h>

void abort(void);

此函数将SIGABRT信号发送给调用进程(进程不应忽略此信号)。

ISO C规定,调用abort将向主机环境递送一个未成功终止的通知,其方法是调用raise(SIGABRT)函数。ISO C要求若捕捉到此信号而且相应信号处理程序返回,abort仍不会返回到其调用者。

system

一个简单的示例程序:

#include "apue.h"

static void sig_int(int signo) {
    printf("caught SIGINT\n");
}

static void sig_chld(int signo) {
    printf("caught SIGCHLD\n");
}

int main() {
    if (signal(SIGINT, sig_int) == SIG_ERR) {
        err_sys("signal(SIGINT) error");
    }
    if (signal(SIGCHLD, sig_chld) == SIG_ERR) {
        err_sys("signal(SIGCHLD) error");
    }
    if (system("/bin/ed") < 0) {
        err_sys("system() error");
    }
    exit(0);
}

这样就能在ed编辑器的使用过程中捕捉特定的信号了(在此处是SIGINT和SIGCHLD)。

system函数一个进行了信号处理的实现:

#include <sys/wait.h>
#include <errno.h>
#include <signal.h>
#include <unistd.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;
    }
    ignore.sa_handler = SIG_IGN;    // ignore SIGINT and SIGQUIT
    sigemptyset(&ignore.sa_mask);
    ignore.sa_flags = 0;
    if (sigaction(SIGINT, &ignore, &saveintr) < 0) {
        return -1;
    }
    if (sigaction(SIGQUIT, &ignore, &savequit) < 0) {
        return -1;
    }
    sigemptyset(&chldmask);
    sigaddset(&chldmask, SIGCHLD);  // now block SIGCHLD
    if ((pid = fork()) < 0) {
        return -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(127);
    } else {
        while (waitpid(pid, &status, 0) < 0) {
            if (errno != EINTR) {
                status = -1;
                break;
            }
        }
    }
    if (sigaction(SIGINT, &saveintr, NULL) < 0) {
        return -1;
    }
    if (sigaction(SIGQUIT, &savequit, NULL) < 0) {
        return -1;
    }
    if (sigprocmask(SIG_SETMASK, &savemask, NULL) < 0) {
        return -1;
    }
    return status;
}

sleep, nanosleep, clock_nanosleep

#include <unistd.h>

unsigned int sleep(unsigned int seconds);
// 返回0或者未休眠完的秒数

#include <time.h>

int nanosleep(const struct timespec* reqtp, struct timespec* remtp);
// 若休眠到指定时间,返回0,出错返回-1

int clock_nanoslepp(clockid_t clock_id, int flags, const struct timespec* reqtp, struct timespec* remtp);
// 若休眠到指定时间,返回0,出错返回错误码

sleep函数使调用进程被挂起直到满足下面两个条件之一:

  1. 已经过了seconds所指定的墙上时钟时间。

  2. 调用进程捕捉到一个信号并从信号处理程序返回。

nanosleep函数与sleep函数类似,但提供了纳秒级的精度。

reqtp参数用秒和纳秒指定了需要休眠的时间长度。如果某个信号中断了休眠间隔,进程并没有终止,remtp参数指向的 timespec 结构就会被设置为未休眠完的时间长度。如果对未休眠完的时间并不感兴趣,可以把该参数置为NULL。

sigqueue

使用排队信号必须做以下几个操作:

  1. 使用sigaction函数安装信号处理程序时指定SA_SIGINFO标志。

  2. 在sigaction结构的sa_sigaction成员中提供信号处理程序。

  3. 使用sigqueue函数发送信号。

    #include <signal.h>
    
    int sigqueue(pid_t pid, int signo, const union sigval value);
    // 返回值:若成功,返回0;若出错,返回−1
    

    sigqueue函数只能把信号发送给单个进程,可以使用value参数向信号处理程序传递整数和指针值,除此之外,sigqueue函数与kill函数类似。

作业控制信号

6个作业控制信号:

  • SIGCHLD 子进程已停止或终止。

  • SIGCONT 如果进程已停止,则使其继续运行。

  • SIGSTOP 停止信号(不能被捕捉或忽略)。

  • SIGTSTP 交互式停止信号。

  • SIGTTIN 后台进程组成员读控制终端。

  • SIGTTOU 后台进程组成员写控制终端。

当对一个进程产生 4 种停止信号(SIGTSTP、SIGSTOP、SIGTTIN或SIGTTOU)中的任意一种时,对该进程的任一未决SIGCONT信号就被丢弃。

与此类似,当对一个进程产生SIGCONT信号时,对同一进程的任一未决停止信号被丢弃。

如果进程是停止的,则SIGCONT的默认动作是继续该进程;否则忽略此信号。

当对一个停止的进程产生一个SIGCONT信号时,该进程继续,即使该信号被阻塞或忽略。

信号名与编号

使用psignal函数可移植地打印与信号编号对应的字符串。

#include <signal.h>

void psignal(int signo, const char *msg);

字符串msg(通常是程序名)输出到标准错误文件,后面跟随一个冒号和一个空格,再后面对该信号的说明,最后是一个换行符。如果msg为NULL,只有信号说明部分输出到标准错误文件。

如果sigaction信号处理程序中有siginfo结构,可以使用psiginfo函数打印信号信息:

#include <signal.h>

void psiginfo(const siginfo_t *info, const char *msg);

可以使用strsignal函数获取信号的字符描述部分:

#include <string.h>

char *strsignal(int signo);
// 返回值:指向描述该信号的字符串的指针

Solaris提供一对函数,一个函数将信号编号映射为信号名,另一个则反之。

#include <signal.h>

int sig2str(int signo, char *str);
int str2sig(const char *str, int *signop);
// 成功返回0,出错返回−1

线程

线程概念

每个线程都包含有表示执行环境所必需的信息,其中包括:进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。

一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符。

线程标识

每个进程有一个进程ID一样,每个线程也有一个线程ID。进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。

pthread_t类型来表示线程ID,为了可移植性,需要使用pthread_equal函数来比较两个线程ID:

#include <pthread.h>

int pthread_equal(pthread_t tid1, pthread_t tid2);
// 相等返回非0数值,不相等返回0

可以调用pthread_self函数来获取自己的线程ID:

#include <pthread.h>

pthread_t pthread_self(void);
// 返回调用线程的线程ID

线程创建

可以通过pthread_create来创建一个新线程:

#include <pthread.h>

int pthread_create(pthread_t* restrict tidp, const pthread_attr_t* restrict attr, void* (*start_rtn)(void*), void* restrict arg);
// 成功返回0,出错返回错误编号

当成功返回时,tidp指向的内存区域存放着新线程的ID。

attr参数用于定制各种不同的线程属性,可以传入NULL来设置为默认属性。

新创建的线程从start_rtn函数的地址开始运行,该函数只有一个无类型指针参数arg。如果需要向start_rtn函数传递的参数有一个以上,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为arg参数传入。

线程终止

如果进程中的任意线程调用了exit、_Exit或者_exit,那么整个进程就会终止。

与此相类似,如果默认的动作是终止进程,那么,发送到线程的信号就会终止整个进程。

线程可以通过3种方式退出,并且可以在不终止整个进程的情况下,停止它的控制流:

  1. 简单地从启动例程中返回,返回值是线程的退出码。

  2. 被同一进程中的其他线程取消。

  3. 调用pthread_exit。

#include <pthread.h>

void pthread_exit(void* rval_ptr);

rval_ptr的值会作为线程的返回值(注意,不是rval_ptr指向的数据而是rval_ptr本身)。

可以使用pthread_join来获取线程的返回值:

#include <pthread.h>

int pthread_join(pthread_t thread, void** rval_ptr);
// 成功返回0,出错返回错误编号

调用线程将一直阻塞,直到指定的线程调用pthread_exit、从启动例程中返回或者被取消。

  • 如果线程简单地从它的启动例程返回,rval_ptr就指向返回值。
  • 如果线程被取消,由rval_ptr指定的内存单元就设置为PTHREAD_CANCELED。

pthread_join自动把线程置于分离状态。如果线程已经处于分离状态,pthread_join调用就会失败,返回EINVAL。

如果对线程的返回值并不感兴趣,那么可以把rval_ptr设置为NULL。

一个简单的示例:

#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() {
    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 2");
    }
    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_cancel函数来请求取消同一进程中的其他线程。

#include <pthread.h>

int pthread_cancel(pthread_t tid);
// 成功返回0,出错返回错误编号

在默认情况下,pthread_cancel 函数会使得由tid标识的线程的行为表现为如同调用thread_exit(PTHREAD_CANCELED)。线程可以选择忽略cancel或者控制如何被cancel。

线程可以通过pthread_cleanup_push和pthread_cleanup_pop来注册线程清理处理程序(类似atexit函数,可以建立多个,并且调用顺序与注册顺序相反):

#include <pthread.h>

void pthread_cleanup_push(void (*rtn)(void*), void* arg);
void pthread_cleanup_pop(int execute);

只有以下几种情况会触发这些线程清理处理程序:

  • 调用pthread_exit时;

  • 响应取消请求时;

  • 用非零execute参数调用pthread_cleanup_pop时。

如果execute参数设置为0,清理函数将不被调用。

pthread_cleanup_pop(0)用来和pthread_cleanup_push配套。

在linux里,这两个函数是用宏来实现的。不配套的话编译就无法通过。

一个使用示例:

#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, "thread 1 first handler") ;
            pthread_cleanup_push(cleanup, "thread 1 second handler") ;
                    printf("thread 1 push complete\n");
                    if (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, "thread 2 first handler") ;
            pthread_cleanup_push(cleanup, "thread 2 second handler") ;
                    printf("thread 2 push complete\n");
                    if (arg)
                         pthread_exit((void *) 2);
            pthread_cleanup_pop(0);
    pthread_cleanup_pop(0);
    pthread_exit((void *) 2);
}

int main() {
    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 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);
}

输出结果(每次运行的结果可能不同):

thread 1 start
thread 1 push complete
thread 2 start
thread 2 push complete
thread 1 exit code 1
cleanup: thread 2 second handler
cleanup: thread 2 first handler
thread 2 exit code 2

线程同步

互斥量

互斥变量用pthread_mutex_t类型表示。

使用互斥变量前,需要对其进行初始化,可以把它设置为常量PTHREAD_MUTEX_INITIALIZER或者调用pthread_mutex_init函数来进行初始化。

如果动态分配互斥量,那么释放内存前需要调用pthread_mutex_destroy。

调用pthread_mutex_lock来对互斥量进行加锁。如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁。调用pthread_mutex_unlock对互斥量解锁。

如果线程不希望被阻塞,可以使用pthread_mutex_trylock尝试对互斥量进行加锁。

  • 如果调用pthread_mutex_trylock时互斥量处于未锁住状态,那么pthread_mutex_trylock将锁住互斥量,不会出现阻塞直接返回0,
  • 否则pthread_mutex_trylock就会失败,不能锁住互斥量,返回EBUSY。
#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t* restrict mutex, const pthread_mutexattr_t* restrict attr);	// attr为NULL时使用默认配置初始化
int pthread_mutex_destroy(pthread_mutex_t* mutex);
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);
int pthread_mutex_trylock(pthread_mutex_t* mutex);
// 成功返回0,出错返回错误编号

当线程试图获取一个已加锁的互斥量时,pthread_mutex_timedlock互斥量原语允许绑定线程阻塞时间。pthread_mutex_timedlock函数与pthread_mutex_lock是基本等价的,但是在达到超时时间值时,pthread_mutex_timedlock不会对互斥量进行加锁,而是返回错误码ETIMEDOUT。

#include <pthread.h>
#include <time.h>

int pthread_mutex_timedlock(pthread_mutex_t* restrict mutex, const struct timespec* restrict tsptr);
// 成功返回0,出错返回错误编号

一个使用示例:

#include "apue.h"
#include <pthread.h>

int main() {
    int err;
    struct timespec tout;
    struct tm* tmp;
    char buf[64];
    pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

    pthread_mutex_lock(&lock);
    printf("mutex is locked\n");
    clock_gettime(CLOCK_REALTIME, &tout);
    tmp = localtime(&tout.tv_sec);
    strftime(buf, sizeof(buf), "%r", tmp);
    printf("current time is %s\n", buf);
    tout.tv_sec += 10;
    /* this could lead to deadlock */
    err = pthread_mutex_timedlock(&lock, &tout);
    clock_gettime(CLOCK_REALTIME, &tout);
    tmp = localtime(&tout.tv_sec);
    strftime(buf, sizeof(buf), "%r", tmp);
    printf("current time is %s\n", buf);
    if (err == 0) {
        printf("mutex locked again\n");
    } else {
        printf("can't lock mutex again: %s\n", strerror(err));
    }
    exit(0);
}

读写锁

读写锁可以有3种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。当读写锁处于读模式锁住的状态,而这时有一个线程试图以写模式获取锁时,读写锁通常会阻塞随后的读模式锁请求。

简而言之,这是一个写者优先的读写锁。

读写锁也叫做共享互斥锁(shared-exclusive lock)。当读写锁是读模式锁住时,就可以说成是以共享模式锁住的。当它是写模式锁住的时候,就可以说成是以互斥模式锁住的。

与互斥量相比,读写锁在使用之前必须初始化(使用PTHREAD_RWLOCK_INITIALIZER或者调用pthread_rwlock_init函数),在释放它们底层的内存之前必须销毁(调用pthread_rwlock_destroy函数)。

要在读模式下锁定读写锁,需要调用pthread_rwlock_rdlock。要在写模式下锁定读写锁,需要调用pthread_rwlock_wrlock。不管以何种方式锁住读写锁,都可以调用pthread_rwlock_unlock进行解锁。

SUS还定义了读写锁原语的条件版本(trylock版本)。

#include <pthread.h>

int pthread_rwlock_init(pthread_rwlock_t* restrict rwlock, const pthread_rwlockattr_t* restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t* rwlock);
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t* rwlock);
// 成功返回0,出错返回错误编号

一个使用示例:

#include <stdlib.h>
#include <pthread.h>

struct job {
    struct job* j_next;
    struct job* j_prev;
    pthread_t j_id;
    // more stuff here
};

struct queue {
    struct job* q_head;
    struct job* q_tail;
    pthread_rwlock_t q_lock;
};

int queue_init(struct queue* qp) {
    int err;
    qp->q_head = NULL;
    qp->q_tail = NULL;
    err = pthread_rwlock_init(&qp->q_lock, NULL);
    if (err != 0) {
        return err;
    }
    return 0;
}

void job_insert(struct queue* qp, struct job* jp) {
    pthread_rwlock_wrlock(&qp->q_lock);
    jp->j_next = qp->q_head;
    jp->j_prev = NULL;
    if (qp->q_head != NULL) {
        qp->q_head->j_prev = jp;
    } else {
        qp->q_tail = jp;
    }
    qp->q_head = jp;
    pthread_rwlock_unlock(&qp->q_lock);
}

void job_append(struct queue* qp, struct 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_remove(struct queue* qp, struct job* jp) {
    pthread_rwlock_wrlock(&qp->q_lock);
    if (jp == qp->q_head) {
        qp->q_head = jp->j_next;
        if (qp->q_tail == jp) {
            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;
        jp->j_prev->j_next = jp->j_next;
    } else {
        jp->j_prev->j_next = jp->j_next;
        jp->j_next->j_prev = jp->j_prev;
    }
    pthread_rwlock_unlock(&qp->q_lock);
}

struct job* job_find(struct queue* qp, pthread_t id) {
    struct 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->j_id, id)) {
            break;
        }
    }
    pthread_rwlock_unlock(&qp->q_lock);
    return jp;
}

SUS同样提供了读写锁的带有超时的加锁函数:

#include <pthread.h>
#include <time.h>

int pthread_rwlock_timedrdlock(pthread_rwlock_t* restrict rwlock, const struct timespec* restrict tsptr);
int pthread_rwlock_timedwlock(pthread_rwlock_t* restrict rwlock, const struct timespec* restrict tsptr);
// 成功返回0,出错返回错误编号

条件变量

条件变量的初始化和摧毁与之前的互斥量和读写锁差不多:

#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,出错返回错误编号

使用pthread_cond_wait来等待条件变量为真:

#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 struct timespec* restrict tsptr);
// 成功返回0,出错返回-1

传递给pthread_cond_wait的互斥量对条件进行保护。调用者把锁住的互斥量传给函数,函数然后自动把调用线程放到等待条件的线程列表上,对互斥量解锁。这就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变化。pthread_cond_wait返回时,互斥量再次被锁住。

有两个函数可以用于通知线程条件已经满足。

#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函数则能唤醒等待该条件的所有线程。

POSIX规范为了简化pthread_cond_signal的实现,允许它在实现的时候唤醒一个以上的线程。

使用示例:

#include <pthread.h>

struct msg {
    struct msg* m_next;
    // more stuff here
};

struct msg* workq;
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;

void process_msg() {
    struct msg* mp;
    for( ; ; ) {
        pthread_mutex_lock(&qlock);
        while (workq == NULL) {
            pthread_cond_wait(&qready, &qlock);
        }
        mp = workq;
        workq = mp->m_next;
        pthread_mutex_unlock(&qlock);
        /* now process the message up */
    }
}

void enqueue_msg(struct msg* mp) {
    pthread_mutex_lock(&qlock);
    mp->m_next = workq;
    workq = mp;
    pthread_mutex_unlock(&qlock);
    pthread_cond_signal(&qready);
}

自旋锁

#include <pthread.h>

int pthread_spin_init(pthread_spinlock_t* lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t* lock);
int pthread_spin_lock(pthread_spinlock_t* lock);
int pthread_spin_unlock(pthread_spinlock_t* lock);
int pthread_spin_trylock(pthread_spinlock_t* lock);
// 成功返回0,出错返回错误编号

pshared参数表示进程共享属性:

  • 如果为PTHREAD_PROCESS_SHARED,则自旋锁能被可以访问锁底层内存的线程所获取,即便那些线程属于不同的进程,情况也是如此。
  • 如果为PTHREAD_PROCESS_PRIVATE,自旋锁就只能被初始化该锁的进程内部的线程所访问。

如果自旋锁当前在解锁状态的话,pthread_spin_lock函数不要自旋就可以对它加锁。如果线程已经对它加锁了,结果就是未定义的。调用pthread_spin_lock会返回EDEADLK错误(或其他错误),或者调用可能会永久自旋。具体行为依赖于实际的实现。试图对没有加锁的自旋锁进行解锁,结果也是未定义的。

屏障

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

#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);
// 若成功返回0,出错返回错误编号

count参数指定在允许所有线程继续运行之前,必须到达屏障的线程数目。

attr参数指定屏障对象的属性(NULL用默认属性初始化屏障)。

可以使用pthread_barrier_wait函数来表明,线程已完成工作,准备等所有其他线程赶上来。

#include <pthread.h>

int pthread_barrier_wait(pthread_barrier_t *barrier);
// 成功返回0或者PTHREAD_BARRIER_SERIAL_THREAD,出错返回错误编号

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

对于一个任意线程,pthread_barrier_wait函数返回了PTHREAD_BARRIER_SERIAL_THREAD。剩下的线程看到的返回值是0。这使得一个线程可以作为主线程,它可以工作在其他所有线程已完成的工作结果上。

一旦达到屏障计数值,而且线程处于非阻塞状态,屏障就可以被重用。但是除非在调用了pthread_barrier_destroy函数之后,又调用了pthread_barrier_init函数对计数用另外的数进行初始化,否则屏障计数不会改变。

使用示例:

#include "apue.h"
#include <pthread.h>
#include <limits.h>
#include <sys/time.h>

#define NTHR 8              // num of threads
#define NUMNUM 8000000L     // num of numbers to sort
#define TNUM (NUMNUM/NTHR)  // num per thread

long nums[NUMNUM];
long snums[NUMNUM];

pthread_barrier_t b;

#ifdef SOLARIS
#define heapsort qsort
#else
extern int heapsort(void*, size_t, size_t, int (*)(const void*, const void*));
#endif

int complong(const void* arg1, const void* arg2) {
    long l1 = *(long*)arg1;
    long l2 = *(long*)arg2;
    if (l1 == l2) {
        return 0;
    } else if (l1 < l2) {
        return -1;
    } else {
        return 1;
    }
}

void* thr_fn(void* arg) {
    long idx = (long)arg;
    heapsort(&nums[idx], TNUM, sizeof(long), complong);
    pthread_barrier_wait(&b);
    return ((void*)0);
}

void merge() {
    long idx[NTHR];
    long i, minidx, sidx, num;
    for (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;
    struct timeval start, end;
    long long startusec, endusec;
    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);
    startusec = start.tv_sec * 1000000 + start.tv_usec;
    endusec = end.tv_sec * 1000000 + end.tv_usec;
    elapsed = (double)(endusec - startusec) / 1000000.0;
    printf("sort took %.4f seconds\n", elapsed);
    for (i = 0; i < NUMNUM; i++) {
        printf("%ld\n", snums[i]);
    }
    exit(0);
}

此代码在linux上无法运行,找不到heapsort函数。(可能需要下载bsd的头文件)

线程控制

线程属性

可以通过pthread_attr_t类型来控制pthread_create函数新建线程的属性:

pthread_attr_t类型的初始化与反初始化:

#include <pthread.h>

int pthread_attr_init(pthread_attr_t* attr);
int pthread_attr_destroy(pthread_attr_t* attr);
// 成功返回0,出错返回错误编号

还可以通过以下函数来获取和设置pthread_attr_t结构中的detachstate线程属性:

#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,出错返回错误编号

detachstate有两个合法值:PTHREAD_CREATE_DETACHED(以分离状态启动线程)和PTHREAD_CREATE_JOINABLE(正常启动程序)。

一个示例:

#include "apue.h"
#include <pthread.h>

int makethread(void* (*fn)(void*), void* arg) {
    int err;
    pthread_t tid;
    pthread_attr_t attr;
    err = pthread_attr_init(&attr);
    if (err != 0) {
        return err;
    }
    err = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    if (err == 0) {
        err = pthread_create(&tid, &attr, fn, arg);
    }
    pthread_attr_destroy(&attr);
    return err;
}

可以通过以下函数对线程栈属性进行管理:

#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函数来改变新建线程的栈位置。

可以通过以下函数来读取或设置线程属性stacksize:

#include <pthread.h>

int pthread_attr_getstacksize(const pthread_attr_t* restrict attr, size_t* restrict stacksize);
int pthread_attr_setstacksize(pthread_attr_t* attr, size_t stacksize);
// 成功返回0,出错返回错误编号

线程属性guardsize控制着线程栈末尾之后用以避免栈溢出的扩展内存的大小。这个属性默认值是由具体实现来定义的,但常用值是系统页大小。可以把guardsize线程属性设置为0,不允许属性的这种特征行为发生:在这种情况下,不会提供警戒缓冲区。同样,如果修改了线程属性stackaddr,系统就认为我们将自己管理栈,进而使栈警戒缓冲区机制无效,这等同于把guardsize线程属性设置为0。

#include <pthread.h>

iint pthread_attr_getguardsize(const pthread_attr_t* restrict attr, size_t* restrict guardsize);
int pthread_attr_setguardsize(pthread_attr_t* attr, size_t guardsize);
// 成功返回0,出错返回错误编号

如果guardsize线程属性被修改了,操作系统可能会把它取为页大小的整数倍。如果线程的栈指针溢出到警戒区域,应用程序就可能通过信号接收到出错信息。

同步属性

互斥量属性

使用pthread_mutexaddr_t类型进行控制互斥量属性:

#include <pthread.h>

int pthread_mutexattr_init(pthread_mutexattr_t* attr);
int pthread_mutexattr_destroy(pthread_mutexattr_t* attr);
// 成功返回0,出错返回错误编号

互斥量的进程共享属性:

#include <pthread.h>

int pthread_mutexattr_getpshared(const pthread_mutexattr_t* restrict attr, int* restrict pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t* attr, int pshared);
// 成功返回0,出错返回错误编号

默认情况下,进程共享互斥量属性设置为PTHREAD_PROCESS_PRIVATE,在当前进程中的多个线程可以访问同一个同步对象。

如果进程共享互斥量属性设置为PTHREAD_PROCESS_SHARED,从多个进程彼此之间共享的内存数据块中分配的互斥量就可以用于这些进程的同步。

互斥量的健壮属性:

#include <pthread.h>

int pthread_mutexattr_getrobust(const pthread_mutexattr_t* restrict attr, int* restrict robust);
int pthread_mutexattr_setrobust(pthread_mutexattr_t* attr, int robust);
// 成功返回0,出错返回错误编号

健壮属性取值有两种可能的情况:

  • 默认为PTHREAD_MUTEX_STALLED,这意味着持有互斥量的进程终止时不需要采取特别的动作。
  • 另一个取值是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);
// 成功返回0,出错返回错误编号

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

互斥量的类型属性:

#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);
// 成功返回0,出错返回错误编号

type参数的可能取值:

  • PTHREAD_MUTEX_NORMAL:标准互斥量类型,不做任何特殊的错误检查或死锁检测。

  • PTHREAD_MUTEX_ERRORCHECK:提供错误检查。

  • PTHREAD_MUTEX_RECURSIVE 此互斥量类型允许同一线程在互斥量解锁之前对该互斥量进行多次加锁。递归互斥量维护锁的计数,在解锁次数和加锁次数不相同的情况下,不会释放锁。所以,如果对一个递归互斥量加锁两次,然后解锁一次,那么这个互斥量将依然处于加锁状态,对它再次解锁以前不能释放该锁。

  • PTHREAD_MUTEX_DEFAULT:提供默认特性和行为。操作系统在实现它的时候可以把这种类型自由地映射到其他互斥量类型中的一种。例如,Linux 3.2.0把这种类型映射为普通的互斥量类型,而FreeBSD 8.0则把它映射为错误检查互斥量类型。

读写锁属性

用pthread_rwlockattr_t类型控制读写锁的属性。

属性的初始化和反初始化:

#include <pthread.h>

int pthread_rwlockattr_init(pthread_rwlockattr_t* attr);
int pthread_rwlockattr_destroy(pthread_rwlockattr_t* attr);
// 成功返回0,出错返回错误编号

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

#include <pthread.h>

int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t* restrict attr, int* restrict pshared);
int pthread_rwlockattr_setpshared(pthread_rwlockattr_t* attr, int pshared);
// 成功返回0,出错返回错误编号

条件变量属性

通过pthread_condattr_t类型控制条件变量的属性:

属性的初始化和反初始化:

#include <pthread.h>

int pthread_condattr_init(pthread_condattr_t* attr);
int pthread_condattr_destroy(pthread_condattr_t* attr);
// 成功返回0,出错返回错误编号

SUS定义了条件变量的两个属性:进程共享和时钟属性:

#include <pthread.h>

int pthread_condattr_getpshared(const pthread_condattr_t* restrict attr, int* restrict pshared);
int pthread_condattr_setpshared(pthread_condattr_t* attr, int pshared);
int pthread_condattr_getclock(const pthread_condattr_t* restrict attr, clockid_t* restrict clock_id);
int pthread_condattr_setclock(pthread_condattr_t* attr, clockid_t* restrict clock_id);
// 成功返回0,出错返回错误编号

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

屏障属性

通过pthread_barrierattr_t类型控制条件变量的属性:

属性的初始化和反初始化:

#include <pthread.h>

int pthread_barrierattr_init(pthread_barrierattr_t* attr);
int pthread_barrierattr_destroy(pthread_barrierattr_t* attr);
// 成功返回0,出错返回错误编号

目前定义的屏障属性只有进程共享属性:

#include <pthread.h>

int pthread_barrierattr_getpshared(const pthread_barrierattr_t* restrict attr, int* restrict pshared);
int pthread_barrierattr_setpshared(pthread_barrierattr_t* attr, int pshared);
// 成功返回0,出错返回错误编号

重入

如果一个函数对多个线程来说是可重入的,就说这个函数就是线程安全的。但这并不能说明对信号处理程序来说该函数也是可重入的。如果函数对异步信号处理程序的重入是安全的,那么就可以说函数是异步信号安全的。

POSIX.1提供了以线程安全的方式管理FILE对象的方法。可以使用flockfile和ftrylockfile获取给定FILE对象关联的锁。这个锁是递归的:当你占有这把锁的时候,还是可以再次获取该锁,而且不会导致死锁。

所有操作FILE对象的标准I/O例程的动作行为必须看起来就像它们内部调用了flockfile和funlockfile。

#include <stdio.h>

int ftrylockfile(FILE* fp);
// 成功返回0,若不能获取锁则返回非0数值
void flockfile(FILE* fp);
void funlockfile(FILE* fp);

为了避免锁带来的读写单个字符的性能下降,出现了不加锁版本的基于字符的标准I/O例程:

#include <stdio.h>

int getchar_unlocked(void);
int getc_unlocked(FILE* fp);
// 成功返回下一个字符,遇到文件尾或者出错返回EOF
int putchar_unlocked(int c);
int putc_unlocked(int c, FILE* fp);
// 成功返回c,出错返回EOF

可重入(线程安全)版本的getenv函数实现:

#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() {
    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_r(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) == 0) && (environ[i][len] == '=')) {
            olen = strlen(&environ[i][len+1]);
            if (olen >= buflen) {
                pthread_mutex_unlock(&env_mutex);
                return ENOSPC;
            }
            strcpy(buf, &environ[i][len+1]);
            pthread_mutex_unlock(&env_mutex);
            return 0;
        }
    }
    pthread_mutex_unlock(&env_mutex);
    return ENOENT;
}

线程特定数据

线程特定数据(thread-specific data),也称为线程私有数据(thread-private data),是存储和查询某个特定线程相关数据的一种机制。每个线程可以访问它自己单独的数据副本,而不需要担心与其他线程的同步访问问题。

在分配线程特定数据之前,需要创建与该数据关联的键。这个键将用于获取对线程特定数据的访问。使用pthread_key_create创建一个键。

#include <pthread.h>

int pthread_key_create(pthread_key_t* keyp, void (*destructor)(void*));
// 成功返回0,出错返回错误编号

除了创建键以外,pthread_key_create 可以为该键关联一个可选择的析构函数。当这个线程退出时,如果数据地址已经被置为非空值,那么析构函数就会被调用,它唯一的参数就是该数据地址。

当线程调用pthread_exit或者线程执行返回,正常退出时,析构函数就会被调用。同样,线程取消时,只有在最后的清理处理程序返回之后,析构函数才会被调用。如果线程调用了exit、_exit、_Exit或abort,或者出现其他非正常的退出时,就不会调用析构函数。

可以使用pthread_key_delete取消键和线程特定数据值之间的关联关系:

#include <pthread.h>

int pthread_key_delete(pthread_key_t key);
// 成功返回0,出错返回错误编号

通过使用pthread_once来保证只初始化一次:

#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_setspecific函数将键和特定数据类型关联起来:

#include <pthread.h>

void* pthread_getspecific(pthread_key_t key);
// 返回线程特定数据值,若没有值与该键关联则返回NULL
int pthread_setspecific(pthread_key_t key, const void* value);
// 成功返回0,出错返回错误编号

线程安全的getenv的兼容版本:

#include <limits.h>
#include <string.h>
#include <pthread.h>
#include <stdlib.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() {
    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 = malloc(MAXSTRINGSZ);
        if (envbuf == NULL) {
            pthread_mutex_unlock(&env_mutex);
            return NULL;
        }
        pthread_setspecific(key, envbuf);
    }
    len = strlen(name);
    for (i = 0; environ[i] != NULL; i++) {
        if ((strncmp(name, environ[i], len) == 0) && (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_cancel函数调用时所呈现的行为。

可取消状态属性可以是PTHREAD_CANCEL_ENABLE,也可以是PTHREAD_CANCEL_DISABLE。线程可以通过调用pthread_setcancelstate修改它的可取消状态。

#include <pthread.h>

int pthread_setcancelstate(int state, int* oldstate);
// 返回值:若成功,返回0;否则,返回错误编号

在默认情况下,线程在取消请求发出以后还是继续运行,直到线程到达某个取消点。取消点是线程检查它是否被取消的一个位置,如果取消了,则按照请求行事。

一些会导致请求点出现的函数请参考APUE第三版P362-363。

线程启动时默认的可取消状态是 PTHREAD_CANCEL_ENABLE。当状态设为PTHREAD_CANCEL_DISABLE时,对pthread_cancel的调用并不会杀死线程。相反,取消请求对这个线程来说还处于挂起状态,当取消状态再次变为PTHREAD_CANCEL_ENABLE时,线程将在下一个取消点上对所有挂起的取消请求进行处理。

可以通过pthread_testcancle函数来添加自己的取消点:

#include <pthread.h>

void pthread_testcancle(void);

调用pthread_testcancel时,如果有某个取消请求正处于挂起状态,而且取消并没有置为无效,那么线程就会被取消。但是,如果取消被置为无效,pthread_testcancel调用就没有任何效果了。

默认的取消类型为推迟取消(PTHREADCANCEL_DEFERRED)。调用pthread_cancel以后,在线程到达取消点之前,并不会出现真正的取消。

还可以将取消类型设置为异步取消(PTHREAD_CANCEL_ASYNCHRONOUS)。使用异步取消时,线程可以在任意时间撤消,不是非得遇到取消点才能被取消。

可以通过调用pthread_setcanceltype来修改取消类型。

#include <pthread.h>

int pthread_setcanceltype(int type, int* oldtype);
// 成功返回0,出错返回错误编号

线程和信号

每个线程都有自己的信号屏蔽字,但是信号的处理是进程中所有线程共享的。

sigprocmask的行为在多线程的进程中并没有定义,线程必须使用pthread_sigmask来设置信号屏蔽字:

#include <signal.h>

int pthread_sigmask(int how, const sigset_t* restrict set, sigset_t* restrict oset);
// 成功返回0,出错返回错误编号

线程可以通过调用sigwait等待一个或多个信号的出现(个人感觉和sigsuspend很像):

#include <signal.h>

int sigwait(const sigset_t *restrict set, int *restrict signop);
// 成功返回0,出错返回错误编号

要把信号发送给进程,可以调用kill。要把信号发送给线程,可以调用pthread_kill。

#include <signal.h>

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

一个示例程序:

#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;
    for ( ; ; ) {
        err = sigwait(&mask, &signo);
        if (err != 0) {
            err_exit(err, "sigwait failed");
        }
        switch (signo) {
            case SIGINT:
                printf("\ninterrupt\n");
                break;
            case SIGQUIT:
                pthread_mutex_lock(&lock);
                quitflag = 1;
                pthread_mutex_unlock(&lock);
                pthread_cond_signal(&waitloc);
                return 0;
            default:
                printf("unexpected signal %d\n", signo);
                exit(1);
        }
    }
}

int main() {
    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 failed");
    }
    err = pthread_create(&tid, NULL, thr_fn, 0);
    if (err != 0) {
        err_exit(err, "pthread_create failed");
    }
    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_sys("SIG_SETMASK error");
    }
    exit(0);
}

线程与fork

当线程调用fork时,就为子进程创建了整个进程地址空间的副本。

子进程通过继承整个地址空间的副本,还从父进程那儿继承了每个互斥量、读写锁和条件变量的状态。如果父进程包含一个以上的线程,子进程在fork返回以后,如果紧接着不是马上调用exec的话,就需要清理锁状态。

可以调用pthread_atfork函数安装最多3个帮助清理锁的函数。

#include <pthread.h>

int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
// 成功返回0,出错返回错误编号

prepare处理程序由父进程在fork创建子进程前调用。这个处理程序的任务是获取父进程定义的所有锁。

parent处理程序是在fork创建子进程以后、返回之前在父进程上下文中调用的。这个处理程序的任务是对prepare处理程序获取的所有锁进行解锁。

child处理程序在fork返回之前在子进程上下文中调用。child处理程序也必须释放prepare处理程序获取的所有锁。

parent和child处理程序是以它们注册时的顺序进行调用的,而prepare处理程序的调用顺序与它们注册时的顺序相反。这样可以允许多个模块注册它们自己的fork处理程序,而且可以保持锁的层次。

线程与I/O

可参考第三章笔记的原子操作部分的pread和pwrite函数。

守护进程

守护进程(daemon)是生存期长的一种进程。它们常常在系统引导装入时启动,仅在系统关闭时才终止。它们没有控制终端,在后台运行的。UNIX系统有很多守护进程,它们执行日常事务活动。

守护进程的特征

系统进程依赖于操作系统实现。父进程ID为0的各进程通常是内核进程,它们作为系统引导装入过程的一部分而启动。(init是个例外,它是一个由内核在引导装入时启动的用户层次的命令。)内核进程是特殊的,通常存在于系统的整个生命期中。它们以超级用户特权运行,无控制终端,无命令行。

大多数守护进程都以超级用户(root)特权运行。所有的守护进程都没有控制终端,其终端名设置为问号。内核守护进程以无控制终端方式启动。用户层守护进程缺少控制终端可能是守护进程调用了setsid的结果。大多数用户层守护进程都是进程组的组长进程以及会话的首进程,而且是这些进程组和会话中的唯一进程(rsyslogd是一个例外)。最后,应当注意的是用户层守护进程的父进程是init进程。

编程规则

编写守护进程程序需要遵循一些基本规则:

  1. 首先要调用umask将文件模式创建屏蔽字设置为一个已知值(通常是0)。
  2. 调用fork,然后使父进程exit。
  3. 调用setsid创建一个新会话。使调用进程:(a)成为新会话的首进程,(b)成为一个新进程组的组长进程,(c)没有控制终端。
  4. 将当前工作目录更改为根目录。或者,某些守护进程还可能会把当前工作目录更改到某个指定位置,并在此位置进行它们的全部工作。例如,行式打印机假脱机守护进程就可能将其工作目录更改到它们的spool目录上。
  5. 关闭不再需要的文件描述符。
  6. 某些守护进程打开/dev/null使其具有文件描述符0、1和2,这样,任何一个试图读标准输入、写标准输出或标准错误的库例程都不会产生任何效果。

示例:

#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;
    struct rlimit r1;
    struct sigaction sa;

    umask(0);
    if (getrlimit(RLIMIT_NOFILE, &r1) < 0) {
        err_quit("%s: can't get file limit", cmd);
    }
    if ((pid = fork()) < 0) {
        err_quit("%s: can't fork", cmd);
    } else if (pid != 0) {
        exit(0);
    }
    setsid();
    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);
    }
    if (chdir("/") < 0) {
        err_quit("%s: can't chdir to /", cmd);
    }
    if (r1.rlim_max == RLIM_INFINITY) {
        r1.rlim_max = 1024;
    }
    for (i = 0; i < r1.rlim_max; i++) {
        close(i);
    }
    fd0 = open("/dev/null", O_RDWR);
    fd1 = dup(0);
    fd2 = dup(0);
    openlog(cmd, LOG_CONS, LOG_DAEMON);
    if (fd0 != 0 || fd1 != 1 || fd2 != 2) {
        syslog(LOG_ERR, "unexpected file descriptors %d %d %d", fd0, fd1, fd2);
        exit(1);
    }
}

不太明白这个函数到底导致了什么结果,编写的测试程序的结果和想象中有点不同。

出错记录

有三种产生日志消息的方法:

  1. 内核例程可以调用log函数。任何一个用户进程都可以通过打开并读/dev/klog设备来读取这些消息。
  2. 大多数用户进程(守护进程)调用syslog(3)函数来产生日志消息。这使消息被发送至UNIX域数据报套接字/dev/log。
  3. 无论一个用户进程是在此主机上,还是在通过TCP/IP网络连接到此主机的其他主机上,都可将日志消息发向UDP端口514。注意,syslog函数从不产生这些UDP数据报,它们要求产生此日志消息的进程进行显式的网络编程。

通常,syslogd守护进程读取所有3种格式的日志消息。此守护进程在启动时读一个配置文件,其文件名一般为/etc/syslog.conf,该文件决定了不同种类的消息应送向何处。

#include <syslog.h>

void openlog(const char* ident, int option, int facility);
void syslog(int priority, const char* format, ...);
void closelog(void);
int setlogmask(int maskpri);
// 返回之前的日志记录优先级屏蔽字

调用openlog是可选择的。如果不调用openlog,则在第一次调用syslog时,自动调用openlog。

调用 closelog也是可选择的,因为它只是关闭曾被用于与syslogd守护进程进行通信的描述符。

调用openlog 使我们可以指定一个ident,以后,此ident将被加至每则日志消息中。ident一般是程序的名称(如cron、inetd)。

option参数是指定各种选项的位屏蔽:

facility参数:

priority参数是facility和level的组合。

level的可能取值(优先级从高到低排序):

如果不调用openlog,或者以facility为0来调用它,那么在调用syslog时,可将facility作为priority参数的一个部分进行说明。

setlogmask函数用于设置进程的记录优先级屏蔽字。它返回调用它之前的屏蔽字。

当设置了记录优先级屏蔽字时,各条消息除非已在记录优先级屏蔽字中进行了设置,否则将不被记录。

注意,试图将记录优先级屏蔽字设置为0并不会有什么作用。

很多平台还提供了syslog的一种变体:

#include <syslog.h>
#include <stdarg.h>

void vsyslog(int priority const char* format, va_list arg);

单实例守护进程

为了正常运作,某些守护进程会实现为,在任一时刻只运行该守护进程的一个副本。

文件和记录锁机制为一种方法提供了基础,该方法保证一个守护进程只有一个副本在运行。如果每一个守护进程创建一个有固定名字的文件,并在该文件的整体上加一把写锁,那么只允许创建一把这样的写锁。在此之后创建写锁的尝试都会失败,这向后续守护进程副本指明已有一个副本正在运行。

文件和记录锁提供了一种方便的互斥机制。如果守护进程在一个文件的整体上得到一把写锁,那么在该守护进程终止时,这把锁将被自动删除。

示例程序:

#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <syslog.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include <sys/stat.h>

#define LOCKFILE "/var/run/daemon.pid"
#define LOCKMODE (S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH)

extern int lockfile(int);

int already_running() {
    int fd;
    char buf[16];
    fd = open(LOCKFILE, O_RDWR|O_CREAT, LOCKMODE);
    if (fd < 0) {
        syslog(LOG_ERR, "can't open %s: %s", LOCKFILE, strerror(errno));
    }
    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;
}

不太懂lockfile函数在哪,没找到😭。

守护进程的惯例

在UNIX系统中,守护进程遵循下列通用惯例:

  • 若守护进程使用锁文件,那么该文件通常存储在/var/run目录中。锁文件的名字通常是name.pid,其中,name是该守护进程或服务的名字。
  • 若守护进程支持配置选项,那么配置文件通常存放在/etc目录中。配置文件的名字通常是name.conf。
  • 守护进程可用命令行启动,但通常它们是由系统初始化脚本之一(/etc/rc*或/etc/init.d/*)启动的。如果在守护进程终止时,应当自动地重新启动它(我们可在/etc/inittab中为该守护进程包括respawn记录项,这样,init就将重新启动该守护进程)。
  • 若一个守护进程有一个配置文件,那么当该守护进程启动时会读该文件,但在此之后一般就不会再查看它。若某个管理员更改了配置文件,那么该守护进程可能需要被停止,然后再启动,以使配置文件的更改生效。为避免此种麻烦,某些守护进程将捕捉SIGHUP信号,当它们接收到该信号时,重新读配置文件。

客户进程-服务器进程模型

守护进程常常用作服务器进程。

一般而言,服务器进程等待客户进程与其联系,提出某种类型的服务要求。

高级I/O

非阻塞I/O

非阻塞I/O使我们可以发出open、read和write这样的I/O操作,但不会永远阻塞。如果这种操作不能完成,则调用立即出错返回,表示该操作如继续执行将阻塞。

对于一个给定的描述符,有两种为其指定非阻塞I/O的方法:

  1. 如果调用open获得描述符,则可指定O_NONBLOCK标志。

  2. 对于已经打开的一个描述符,则可调用fcntl函数打开O_NONBLOCK文件状态标志。

一个简单的示例:

#include "apue.h"
#include <errno.h>
#include <fcntl.h>

char buf[500000];

int main() {
    int ntowrite, nwrite;
    char* ptr;
    ntowrite = read(STDIN_FILENO, buf, sizeof(buf));
    fprintf(stderr, "read %d bytes\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);
}

记录锁

记录锁(record locking)的功能是:当一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。

fcntl记录锁

#include <fcntl.h>

int fcntl(int fd, int cmd, .../* struct flock* flockptr */);
// 成功的返回值依赖于cmd,出错返回-1

对于记录锁,cmd是F_GETLK、F_SETLK或F_SETLKW,第三个参数使用flockptr。

flockptr是一个指向flock结构的指针。

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;	/* 相对l_whence的偏移量 */
    off_t l_len;	/* 加锁区域长度, 0代表一直到EOF */
    pid_t l_pid;	/* cmd为F_GETLK,返回时, pid代表的进程持有的锁能阻塞当前进程 */
};

共享读锁(l_type为L_RDLCK)和独占性写锁(L_WRLCK)的基本规则是:任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定字节上只能有一个进程有一把独占写锁。

cmd三种取值的作用:

  • F_GETLK:判断由flockptr所描述的锁是否会被另外一把锁所阻塞。如果存在一把锁,它阻止创建由flockptr所描述的锁,则该现有锁的信息将重写flockptr指向的信息。如果不存在这种情况,则除了将l_type设置为F_UNLCK之外,flockptr所指向结构中的其他信息保持不变。

  • F_SETLK:设置由flockptr所描述的锁。如果我们试图获得一把读锁(l_type 为F_RDLCK)或写锁(l_type为F_WRLCK),而兼容性规则阻止系统给我们这把锁,那么fcntl会立即出错返回,此时errno设置为EACCES或EAGAIN。此命令也用来清除由flockptr指定的锁(l_type为F_UNLCK)。

  • F_SETLKW:这个命令是F_SETLK的阻塞版本(W for Wait)。如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁而不能被授予,那么调用进程会被置为休眠。如果请求创建的锁已经可用,或者休眠由信号中断,则该进程被唤醒。

在设置或释放文件上的一把锁时,系统按要求组合或分裂相邻区。

锁的隐含继承和释放

记录锁的自动继承和释放的3条规则:

  1. 锁与进程和文件两者相关联。当一个进程终止时,它所建立的锁全部释放;无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放。
  2. fork产生的子进程不继承父进程所设置的锁。
  3. 在执行exec后,新程序可以继承原执行程序的锁。但是如果对一个文件描述符设置了执行时关闭标志,那么当作为exec的一部分关闭该文件描述符时,将释放相应文件的所有锁。

建议性锁和强制性锁

建议性锁不能阻止对文件有写权限的任何其他进程写这个文件。

强制性锁会让内核检查每一个open、read和write,验证调用进程是否违背了正在访问的文件上的某一把锁。

强制性锁有时也称为强迫方式锁(enforcement-mode locking)。

对一个特定文件打开其设置组ID位、关闭其组执行位便开启了对该文件的强制性锁机制。

强制性锁对其他进程的read和write的影响:

image-20210511135401997

I/O多路转接

select和pselect

select函数使我们可以执行I/O多路转接。传给select的参数告诉内核:

  • 我们所关心的描述符;
  • 对于每个描述符我们所关心的条件(是否想从一个给定的描述符读,是否想写一个给定的描述符,是否关心一个给定描述符的异常条件);
  • 愿意等待多长时间(可以永远等待、等待一个固定的时间或者根本不等待)。

从select返回时,内核告诉我们:

  • 已准备好的描述符的总数量;
  • 对于读、写或异常这3个条件中的每一个,哪些描述符已准备好。
#include <sys/select.h>

int select(int maxfdp1, fd_set* restrict readfds, fd_set* restrict writefds, fd_set* restrict exceptfds, struct timeval* restrict tvptr);
// 返回准备就绪的描述符数目,超时返回0,出错返回−1

tvptr参数指定愿意等待的时间长度:

  • tvptr == NULL:永远等待。如果捕捉到一个信号则中断此无限期等待。当所指定的描述符中的一个已准备好或捕捉到一个信号则返回。如果捕捉到一个信号,则select返回-1,errno设置为EINTR。

  • tvptr->tv_sec == 0 && tvptr->tv_usec == 0:不等待。测试所有指定的描述符并立即返回。可以用来查询多个描述符状态的状态。

  • tvptr->tv_sec != 0 || tvptr->tv_usec != 0:当指定的描述符之一已准备好,或当指定的时间值已经超过时立即返回。如果在超时到期时还没有一个描述符准备好,则返回0。这种等待同样可被捕捉到的信号中断。

readfds、writefds和exceptfds都是指向描述符集的指针。

每个描述符集存储在一个fd_set数据类型中。它可以为每一个可能的描述符保持一位。

fd_set数据类型的相关操作:

#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将一个fd_set变量的所有位设置为0。

  • 要开启描述符集中的一位,可以调用FD_SET。

  • 调用FD_CLR可以清除一位。

  • 可以调用FD_ISSET测试描述符集中的一个指定位是否已打开。

maxfdp1参数是三个描述符集中最大文件描述符的编号值加1。这个参数用于提高遍历文件描述符的效率。

select的返回值:

  • 返回值-1:出错了。如在所指定的描述符一个都没准备好时捕捉到一个信号。在此种情况下,一个描述符集都不修改。

  • 返回值0:没有描述符准备好就超时了。此时,所有描述符集都会置0。

  • 正的返回值:说明了已经准备好的描述符数。该值是3个描述符集中已准备好的描述符数之和。

如果在一个描述符上碰到了文件尾端,则select会认为该描述符是可读的。然后调用read,它返回0,这是UNIX系统指示到达文件尾端的方法。

POSIX.1定义了一个select的变体,称为pselect:

#include <sys/select.h>

int pselect(int maxfdp1, fd_set* restrict readfds, fd_set* restrict writefds, fd_set* restrict exceptfds, const struct timespec* restrict tsptr, const sigset_t* restrict sigmask);
// 返回准备就绪的描述符数目,超时返回0,出错返回−1
  • select的超时值用timeval结构指定,而pselect使用timespec结构(更加精确)。

  • pselect的超时值被声明为const,这保证了调用pselect不会改变此值。

  • sigmask指定信号屏蔽字,在调用pselect时,以原子操作的方式安装该信号屏蔽字。在返回时,恢复以前的信号屏蔽字。(为NULL时和select相同)

poll

#include <poll.h>

int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
// 返回准备就绪的描述符数目,超时返回0,出错返回-1

fdarray数组中的每个元素指定一个描述符编号以及我们对该描述符感兴趣的条件。nfds参数指定数组的长度。

其中struct pollfd结构如下:

struct pollfd {
    int fd;			/* file descriptor to check, or < 0 to ignore */
    short events; 	/* events of interest on fd */
    short revents; 	/* events that occurred on fd */
};

events成员的取值:

image-20210511142942948

timeout的可能取值:

  • timeout == -1:永远等待。当所指定的描述符中的一个已准备好,或捕捉到一个信号时返回。如果捕捉到一个信号,则poll返回-1,errno设置为EINTR。

  • timeout == 0:不等待。测试所有描述符并立即返回。

  • timeout > 0:等待timeout毫秒。当指定的描述符之一已准备好,或timeout到期时立即返回。如果timeout到期时还没有一个描述符准备好,则返回值是0。

异步I/O

POSIX异步I/O接口为对不同类型的文件进行异步I/O提供了一套一致的方法。

异步I/O基于aiocb(AIO控制块)结构,该结构至少包括下面这些字段:

struct aiocb {
    int aio_fildes;					/* 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_sigevent字段控制在I/O事件完成后,如何通知应用程序。

这个字段通过sigevent结构来描述:

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字段指定的信号。

  • SIGEV_THREAD:当异步I/O请求完成时,由sigev_notify_function字段指定的函数被调用。sigev_value字段被传入作为它的唯一参数。除非sigev_notify_attributes字段被设定为pthread属性结构的地址,且该结构指定了一个另外的线程属性,否则该函数将在分离状态下的一个单独的线程中执行。

调用aio_read函数来进行异步读操作,调用aio_write函数来进行异步写操作。

#include <aio.h>

int aio_read(struct aiocb* aiocb);
int aio_write(struct aiocb* aiocb);
// 成功返回0,出错返回−1

可以调用aio_fsync函数强制所有等待中的异步操作不等待而写入持久化的存储中。

#include <aio.h>

int aio_fsync(int op, struct aiocb* aiocb);
// 成功返回0,出错返回−1

AIO控制块中的aio_fildes字段指定了其异步写操作被同步的文件。

  • 如果op参数设定为O_DSYNC,那么操作执行起来就会像调用了fdatasync一样。
  • 如果op参数设定为O_SYNC,那么操作执行起来就会像调用了fsync一样。

可以调用aio_error函数获取异步读、写或者同步操作的完成状态:

#include <aio.h>

int aio_error(const struct aiocb* aiocb);

返回值有4种情况:

  • 0:异步操作成功完成。需要调用aio_return函数获取操作返回值。

  • −1:对aio_error的调用失败。

  • EINPROGRESS:异步读、写或同步操作仍在等待。

  • 其他的返回值是相关的异步操作失败返回的错误码。

如果异步操作成功,可以调用aio_return函数来获取异步操作的返回值。

#include <aio.h>

ssize_t aio_return(const struct aiocb* aiocb);

返回值:

  • 如果aio_return函数本身失败,会返回−1,并设置errno。
  • 其他情况下,它将返回异步操作的结果,即会返回read、write或者fsync在被成功调用时可能返回的结果。

需要在异步操作成功后再调用aio_return函数。操作完成之前的结果是未定义的。

对每个异步操作只能调用一次aio_return。调用后操作系统就可以释放掉包含了I/O操作返回值的记录。

如果完成了所有事务后还有异步操作未完成时,可以调用aio_suspend函数来阻塞进程,直到操作完成。

#include <aio.h>

int aio_suspend(const struct aiocb* const list[], int nent, const struct timespec* timeout);
// 成功返回0,出错返回−1

返回值:

  • 如果被一个信号中断,返回-1,并将errno设置为EINTR。
  • 如果在没有任何I/O操作完成的情况下,阻塞的时间超过了函数中可选的timeout参数所指定的时间限制,那么aio_suspend将返回-1,并将errno设置为EAGAIN。
  • 如果有任何I/O操作完成,aio_suspend将返回0。

如果在我们调用aio_suspend操作时,所有的异步I/O操作都已完成,那么aio_suspend将在不阻塞的情况下直接返回。

timeout参数为空指针时表示不设置任何时间限制。

list参数是一个指向AIO控制块数组的指针,nent参数表明了数组中的条目数。数组中的空指针会被跳过,其他条目都必须指向已用于初始化异步I/O操作的AIO控制块。

可以使用aio_cancel函数尝试取消不想再完成的等待中的异步I/O操作。

#include <aio.h>

int aio_cancel(int fd, struct aiocb* aiocb);

返回值:

  • AIO_ALLDONE:所有操作在尝试取消它们之前已经完成。

  • AIO_CANCELED:所有要求的操作已被取消。

  • AIO_NOTCANCELED:至少有一个要求的操作没有被取消。

  • -1:对aio_cancel的调用失败,错误码将被存储在errno中。

fd参数指定了未完成的异步I/O操作的文件描述符。如果aiocb参数为NULL,系统将会尝试取消所有该文件上未完成的异步I/O操作。

无法保证系统能够取消正在进程中的任何操作。

如果异步I/O操作被成功取消,对相应的AIO控制块调用aio_error函数将会返回错误ECANCELED。

如果操作不能被取消,那么相应的AIO控制块不会因为对aio_cancel的调用而被修改。

lio_listio函数既可以以同步方式调用,也可以以异步方式调用。该函数提交一系列由一个AIO控制块列表描述的I/O请求。

#include <aio.h>

int lio_listio(int mode, struct aiocb* restrict const list[restrict], int nent, struct sigevent* restrict sigev);
// 成功返回0,出错返回−1

mode参数的可能值:

  • LIO_WAIT:函数将在所有由列表指定的I/O操作完成后返回。在这种情况下,sigev参数将被忽略。

  • LIO_NOWAIT:函数将在I/O请求入队后立即返回。进程将在所有I/O操作完成后,按照sigev参数指定的,被异步地通知。如果不想被通知,可以把sigev设定为NULL。

    每个AIO控制块本身也可能启用了在各自操作完成时的异步通知。被sigev参数指定的异步通知是在此之外另加的,并且只会在所有的I/O操作完成后发送。

list参数指向AIO控制块列表,该列表指定了要运行的I/O操作。nent参数指定了数组中的元素个数。列表中的NULL条目将被忽略。

struct aiocb结构中的aio_lio_opcode字段指定了该操作是一个读操作(LIO_READ)、写操作(LIO_WRITE),还是将被忽略的空操作(LIO_NOP)。读操作会按照对应的AIO控制块被传给了aio_read函数来处理。写操作会按照对应的AIO控制块被传给了aio_write函数来处理。

一个异步I/O的示例程序:

#include "apue.h"
#include <ctype.h>
#include <fcntl.h>
#include <aio.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 aiocd* 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_RDWR|O_CREAT|O_TRUNC, FILE_MODE)) < 0) {
        err_sys("can't create %s", argv[2]);
    }
    if (fstat(ifd, &sbuf) < 0) {
        err_sys("fstat error");
    }
    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;
    for ( ; ; ) {
        for (i = 0; i < NBUF; i++) {
            switch (bufs[i].op) {
                case UNUSED:
                    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");
                        }
                    }
                    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].op = WRITE_PENDING;
                    bufs[i].aiocb.aio_fildes = ofd;
                    bufs[i].aiocb.aio_nbytes = n;
                    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, "write failed");
                        }
                    }
                    if ((n = aio_return(&bufs[i].aiocb)) < 0) {
                        err_sys("aio_return failed");
                    }
                    if (n != bufs[i].aiocb.aio_nbytes) {
                        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[0].aiocb) < 0) {
        err_sys("aio_fsync failed");
    }
    exit(0);
}

readv和writev

readv和writev函数用于在一次函数调用中读、写多个非连续缓冲区。

这两个函数也被称为散布读(scatter read)和聚集写(gather write)。

#include <sys/aio.h>

ssize_t readv(int fd, const struct iovec* iov, int iovcnt);
ssize_t writev(int fd, const struct iovec* iov, int iovcnt);
// 成功返回已读或已写的字节数,出错返回−1

其中struct iovec结构如下:

struct iovec {
	void* iov_base; /* starting address of buffer */
	size_t iov_len; /* size of buffer */
};

iovcnt参数指定iov数组中的元素数。

writev返回输出的字节总数,通常应等于所有缓冲区长度之和。

readn和writen

管道、FIFO以及某些设备(特别是终端和网络)有以下两种性质:

  1. 一次read操作所返回的数据可能少于所要求的数据,即使还没达到文件尾端也可能是这样。这不是一个错误,应当继续读该设备。

  2. 一次write操作的返回值可能少于指定输出的字节数。这可能是由某个因素造成的,例如,内核输出缓冲区变满。这也不是错误,应当继续写余下的数据。

    通常,只有非阻塞描述符,或捕捉到一个信号时,才发生这种write的中途返回。

我们可以实现两个函数来读、写指定的N字节数据:

#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 (nleft == n) {
                return -1;
            } else {
                break;
            }
        }
        nleft -= nread;
        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 (nleft == n) {
                return -1;
            } else {
                break;
            }
        }
        nleft -= nwritten;
        ptr += nwritten;
    }
    return n - nleft;
}

存储映射I/O

存储映射I/O(memory-mapped I/O)能将一个磁盘文件映射到存储空间中的一个缓冲区上,于是,当从缓冲区中取数据时,就相当于读文件中的相应字节。将数据存入缓冲区时,相应字节就自动写入文件。这样,就可以在不使用read和write的情况下执行I/O。

我们可以使用mmap函数来告诉内核将一个给定的文件映射到一个存储区域中:

#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是要映射字节在文件中的起始偏移量。

prot参数指定了映射存储区的保护要求:

image-20210511162827041

可将prot参数指定为PROT_NONE,也可指定为PROT_READ、PROT_WRITE和PROT_EXEC的任意组合的按位或。

flag参数的可能值:

  • MAP_FIXED:返回值必须等于addr。如果未指定此标志,而且addr非0,则内核只把addr视为在何处设置映射区的一种建议,但是不保证会使用所要求的地址。

  • MAP_SHARED:这一标志描述了本进程对映射区所进行的存储操作的配置。此标志指定存储操作修改映射文件,也就是,存储操作相当于对该文件的 write。

  • MAP_PRIVATE:本标志说明,对映射区的存储操作导致创建该映射文件的一个私有副本。所有后来对该映射区的引用都是引用该副本。

    此标志的一种用途是用于调试程序,它将程序文件的正文部分映射至存储区,但允许用户修改其中的指令。任何修改只影响程序文件的副本,而不影响原文件。

必须指定MAP_SHARED和MAP_PRIVATE中的一个且只能指定一个。

off的值和addr的值(如果指定了MAP_FIXED)通常被要求是系统虚拟存储页长度的倍数。

信号SIGSEGV通常用于指示进程试图访问对它不可用的存储区。如果映射存储区被mmap指定成了只读的,那么进程试图将数据存入这个映射存储区的时候,也会产生此信号。

如果映射区的某个部分在访问时已不存在,则产生SIGBUS信号。

子进程能通过fork继承存储映射区(因为子进程复制父进程地址空间,而存储映射区是该地址空间中的一部分),但是由于同样的原因,新程序则不能通过exec继承存储映射区。

可以调用mprotect函数来更改一个现有映射的权限:

#include <sys/mman.h>

int mprotect(void* addr, size_t len, int prot);
// 成功返回0,出错返回-1

prot的合法值与mmap中prot参数的一样.

如果共享映射中的页已修改,那么可以调用msync将该页冲洗到被映射的文件中。msync函数类似于fsync,但作用于存储映射区。

#include <sys/mman.h>

int msync(void* addr, size_t len, int flags);
// 成功返回0,出错返回-1

如果映射是私有的,那么不修改被映射的文件。

flags参数控制如何冲洗存储区:

  • 可以指定MS_ASYNC标志来简单地调试要写的页。
  • 如果希望在返回之前等待写操作完成,可以指定MS_SYNC标志。
  • MS_INVALIDATE是一个可选标志,允许我们通知操作系统丢弃那些与底层存储器没有同步的页。

一定要指定MS_ASYNC和MS_SYNC中的一个。

当进程终止时,会自动解除存储映射区的映射,或者直接调用munmap函数也可以解除映射区。

#include <sys/mman.h>

int munmap(void* addr, size_t len);
// 成功返回0,出错返回-1

调用munmap并不会使映射区的内容写到磁盘文件上。对于MAP_SHARED区磁盘文件的更新,会在我们将数据写到存储映射区后的某个时刻,按内核虚拟存储算法自动进行。在存储区解除映射后,对MAP_PRIVATE存储区的修改会被丢弃。

关闭映射存储区时使用的文件描述符并不解除映射区。

一个简单的示例(使用存储映射I/O复制文件):

#include "apue.h"
#include <fcntl.h>
#include <sys/mman.h>

#define COPYINCR (1024*1024*1024) // 1 GB

int main(int argc, char *argv[]) {
    int fdin, fdout;
    void *src, *dst;
    size_t copysz;
    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 reading", argv[1]);
    }
    if ((fdout = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, FILE_MODE)) < 0) {
        err_sys("can't open %s for writing", argv[2]);
    }
    if (fstat(fdin, &sbuf) < 0) {
        err_sys("fstat error");
    }
    if (ftruncate(fdout, sbuf.st_size) < 0) {
        err_sys("ftruncate error");
    }
    while (fsz < sbuf.st_size) {
        if ((sbuf.st_size - fsz) > COPYINCR) {
            copysz = COPYINCR;
        } else {
            copysz = sbuf.st_size - fsz;
        }
        if ((src = mmap(0, copysz, PROT_READ, MAP_SHARED, fdin, fsz)) == MAP_FAILED) {
            err_sys("mmap error for input");
        }
        if ((dst = mmap(0, copysz, PROT_READ | PROT_WRITE, MAP_SHARED, fdout, fsz)) == MAP_FAILED) {
            err_sys("mmap error for output");
        }
        memcpy(dst, src, copysz);
        munmap(src, copysz);
        munmap(dst, copysz);
        fsz += copysz;
    }
    exit(0);
}

进程间通信

进程间通信,InterProcess Communication, 简称IPC。

管道

可以通过pipe函数来创建一个管道:

#include <unistd.h>

int pipe(int fd[2]);
// 成功返回0,出错返回-1

成功返回后,fd[0]为读而打开,fd[1]为写而打开。fd[1]的输出是fd[0]的输入。

fstat函数对管道的每一端都返回一个FIFO类型的文件描述符。可以用S_ISFIFO宏来测试管道。

通常,进程会先调用pipe,接着调用fork,从而创建从父进程与子进程之间的IPC通道。

  • 对于父进程到子进程的通道。父进程关闭管道的读端(fd[0]),子进程关闭写端(fd[1])。
  • 对于从子进程到父进程的管道,父进程关闭写端(fd[1]),子进程关闭读端(fd[0])。

当读(read)一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,表示文件结束。

当写(write)一个读端已被关闭的管道时,产生信号SIGPIPE。如果忽略该信号或者捕捉该信号并从其处理程序返回,则write返回−1,errno设置为EPIPE。

一个简单的示例:

#include "apue.h"

int main() {
    int n;
    int fd[2];
    pid_t pid;
    char line[MAXLINE];
    if (pipe(fd) < 0) {
        err_sys("pipe error");
    }
    if ((pid = fork()) < 0) {
        err_sys("fork error");
    } else if (pid > 0) {
        close(fd[0]);
        write(fd[1], "hello world!\n", 12);
    } else {
        close(fd[1]);
        n = read(fd[0], line, MAXLINE);
        write(STDOUT_FILENO, line, n);
    }
    exit(0);
}

TELL_WAIT系列函数的使用管道的实现:

#include "apue.h"

static int pfd1[2], pfd2[2];

void TELL_WAIT(void) {
    if (pipe(pfd1) < 0 || pipe(pfd2) < 0) {
        err_sys("pipe error");
    }
}

void TELL_PARENT(pid_t pid) {
    if (write(pfd2[1], "c", 1) != 1) {
        err_sys("write error");
    }
}

void WAIT_PARENT(void) {
    char c;
    if (read(pfd1[0], &c, 1) != 1) {
        err_sys("read error");
    }
    if (c != 'p') {
        err_quit("WAIT_PARENT: incorrect data");
    }
}

void TELL_CHILD(pid_t pid) {
    if (write(pfd1[1], "p", 1) != 1) {
        err_sys("write error");
    }
}

void WAIT_CHILD(void) {
    char c;
    if (read(pfd2[0], &c, 1) != 1) {
        err_sys("read error");
    }
    if (c != 'c') {
        err_quit("WAIT_CHILD: incorrect data");
    }
}

popen和pclose

#include <stdio.h>

FILE* popen(const char* cmdstring, const char* type);
// 成功返回文件指针,出错返回NULL
int pclose(FILE* fp);
// 成功返返回cmdstring的终止状态,出错返回-1

函数popen先执行fork,然后调用exec执行cmdstring,并且返回一个标准I/O文件指针。

  • 如果type是"r",则文件指针连接到cmdstring的标准输出。

  • 如果type是"w",则文件指针连接到cmdstring的标准输入,

pclose函数关闭标准I/O流,等待命令终止,然后返回shell的终止状态。如果shell不能被执行,则pclose返回的终止状态与shell执行exit(127)一样。

示例程序(popen和pclose的实现):

#include "apue.h"
#include <errno.h>
#include <fcntl.h>
#include <sys/wait.h>

static pid_t* childpid = NULL;  // pointer to array allocated at run-time
static int maxfd;

FILE* popen(const char* cmdstring, const char* type) {
    int i;
    int pfd[2];
    pid_t pid;
    FILE* fp;
    if ((type[0] != 'r' && type[0] != 'w') || type[1] != 0) {
        errno = EINVAL;
        return NULL;
    }
    if (childpid == NULL) {
        maxfd = open_max();
        if ((childpid = calloc(maxfd, sizeof(pid_t))) == NULL) {
            return NULL;
        }
    }
    if (pipe(pfd) < 0) {
        return NULL;
    }
    if (pfd[0] >= maxfd || pfd[1] >= maxfd) {
        close(pfd[0]);
        close(pfd[1]);
        errno = EMFILE;
        return NULL;
    }
    if ((pid = fork()) < 0) {
        return NULL;
    } else if (pid == 0) {
        if (*type == 'r') {
            close(pfd[0]);
            if (pfd[1] != STDOUT_FILENO) {
                dup2(pfd[1], STDOUT_FILENO);
                close(pfd[1]);
            }
        } else {
            close(pfd[1]);
            if (pfd[0] != STDIN_FILENO) {
                dup2(pfd[0], STDIN_FILENO);
                close(pfd[0]);
            }
        }
        /* close all descriptors in childpid[] */
        /* to comply with POSIX.1 */
        for (i = 0; i < maxfd; i++) {
            if (childpid[i] > 0) {
                close(i);
            }
        }
        execl("/bin/sh", "sh", "-c", cmdstring, (char*)0);
        _exit(127);
    }
    /* parent */
    if (*type == 'r') {
        close(pfd[1]);
        if ((fp = fdopen(pfd[0], type)) == NULL) {
            return NULL;
        }
    } else {
        close(pfd[0]);
        if ((fp = fdopen(pfd[1], type)) == NULL) {
            return NULL;
        }
    }
    childpid[fileno(fp)] = pid; // remember child pid for this fd
    return fp;
}

int pclose(FILE* fp) {
    int fd, stat;
    pid_t pid;
    if (childpid == NULL) {
        errno = EINVAL;
        return -1;
    }
    fd = fileno(fp);
    if (fd > maxfd) {
        errno = EINVAL;
        return -1;
    }
    if ((pid = childpid[fd]) == 0) {
        errno = EINVAL;
        return -1;
    }
    childpid[fd] = 0;
    if (fclose(fp) == EOF) {
        return -1;
    }
    while (waitpid(pid, &stat, 0) < 0) {
        if (errno != EINTR) {
            return -1;
        }
    }
    return stat;
}

协同进程

UNIX系统过滤程序从标准输入读取数据,向标准输出写数据。当一个过滤程序既产生某个过滤程序的输入,又读取该过滤程序的输出时,它就变成了协同进程(coprocess)。

FIFO

FIFO又被称为命名管道

未命名的管道只能在两个相关的进程之间使用,而且这两个相关的进程还要有一个共同的创建了它们的祖先进程。但是,通过FIFO,不相关的进程也能交换数据。

可以使用mkfifo函数来创建一个FIFO:

#include <sys/stat.h>

int mkfifo(const char* path, mode_t mode);
int mkfifoat(int fd, const char* path, mode_t mode);
// 成功返回0,出错返回-1

mode参数的可选值与open函数中mode参数的相同.

带at类型的函数依旧是原来的模式:

  • 如果path参数指定的是绝对路径名,则fd参数会被忽略,mkfifoat函数的行为和mkfifo类似。
  • 如果path参数指定的是相对路径名,则fd参数是一个打开目录的有效文件描述符,。
  • 如果path参数指定的是相对路径名,并且fd参数有一个特殊值AT_FDCWD,则路径名以当前目录开始,mkfifoat和mkfifo类似。

创建FIFO后,要使用open来打开该FIFO。

open FIFO时的非阻塞标志(O_NONBLOCK)的影响:

  • 在一般情况下(没有指定O_NONBLOCK),只读open要阻塞到某个其他进程为写而打开这个FIFO为止。只写open要阻塞到某个其他进程为读而打开它为止。

  • 如果指定了O_NONBLOCK,则只读open立即返回。如果没有进程为读而打开一个FIFO,那么只写open将返回−1,并将errno设置成ENXIO。

XSI IPC

有三种XSI IPC:消息队列、信号量和共享存储器。

标识符和键

每个内核中的IPC结构(消息队列、信号量或共享存储段)都用一个非负整数的标识符(identifier)加以引用。

每个IPC对象都与一个键(key,对应的类型为key_t)相关联,将这个键作为该对象的外部名。

使客户进程和服务器进程在同一IPC结构上汇聚的方法:

  1. 服务器进程可以指定键IPC_PRIVATE创建一个新IPC结构,将返回的标识符存放在某处(如一个文件)以便客户进程取用。键IPC_PRIVATE保证服务器进程创建一个新IPC结构。
  2. 在一个公用头文件中定义一个客户进程和服务器进程都认可的键。然后服务器进程指定此键创建一个新的IPC结构。
  3. 客户进程和服务器进程认同一个路径名和项目ID(项目ID是0~255之间的字符值),接着,调用函数ftok将这两个值变换为一个键。然后在方法(2)中使用此键。
#include <sys/ipc.h>

key_t ftok(const char* path, int fd);
// 成功返回键,出错返回(key_t)-1

决不能指定IPC_PRIVATE作为键来引用一个现有队列,这个特殊的键值总是用于创建一个新队列。

如果希望创建一个新的IPC结构,而且要确保没有引用具有同一标识符的一个现有IPC结构,那么必须在flag中同时指定IPC_CREAT和IPC_EXCL位。这样做了以后,如果IPC结构已经存在就会造成出错,返回EEXIST。

权限结构

XSI IPC为每个IPC结构都关联了ipc_perm结构。该结构规定了权限和所有者,它至少包括下列成员:

struct ipc_perm {
    uid_t uid; 		/* owner's effective user id */
    gid_t gid; 		/* owner's effective group id */
    uid_t cuid; 	/* creator's effective user id */
    gid_t cgid; 	/* creator's effective group id */
    mode_t mode; 	/* access modes */
    /* more fi*/
};

mode字段是S_IRUSR,S_IWUSR等9个值的组合。

消息队列

消息队列是消息的链接表,存储在内核中,由消息队列标识符(队列ID)标识。

msgget用于创建一个新队列或打开一个现有队列。msgsnd将新消息添加到队列尾端。msgrcv用于从队列中取消息。

每个队列都有一个msqid_ds结构与其相关联:

struct msqid_ds {
    struct ipc_perm msg_perm;	// 权限结构,可参考上一部分
    msgqnum_t msg_qnum;			// num of messages on queue
    msglen_t msg_qbytes;		// max num of bytes on queue
    pid_t msg_lspid;			// pid of last msgsnd()
    pid_t msg_lrpid;			// pid of last msgrcv()
    time_t msg_stime;			// last-msgsnd() time
    time_t msg_rtime;			// last-msgrcv() time
    time_t msg_ctime;			// last change time
};

msgget的原型如下:

#include <sys/msg.h>

int msgget(key_t key, int flag);
// 成功返回消息队列ID,出错返回-1

创建新队列时,会对msqid_ds结构进行初始化:

  • 初始化ipc-perm结构。其中的mode成员会按flag中的相应权限位设置。flag的值如下:

    image-20210512091501715

  • msg_qnum、msg_lspid、msg_lrpid、msg_stime和msg_rtime都设置为0。

  • msg_ctime设置为当前时间。

  • msg_qbytes设置为系统限制值。

返回的队列ID可以用于其他几个函数。

msgctl是队列相关的垃圾桶函数(即能干很多事情):

#include <sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds* buf);
// 成功返回0,出错返回-1

msqid即为队列ID。

cmd的取值有以下几种情况:

  • IPC_STAT:将此队列的msqid_ds结构存放在buf指向的结构中。

  • IPC_SET:将字段 msg_perm.uid、msg_perm.gid、msg_perm.mode 和 msg_qbytes从buf指向的结构复制到与这个队列相关的msqid_ds结构中。

  • IPC_RMID:从系统中删除该消息队列以及仍在该队列中的所有数据。这种删除立即生效。仍在使用这一消息队列的其他进程在它们下一次试图对此队列进行操作时,将得到EIDRM错误。

后面两条命令有权限要求:要么进程的有效用户ID等于msg_perm.cuid或msg_perm.uid,要么具有超级用户特权。并且只有超级用户才能增加msg_qbytes的值。

msgsnd用于将数据放到消息队列中:

#include <sys/msg.h>

int msgsnd(int msqid, const void* ptr, size_t nbytes, int flag);
// 成功返回0,出错返回-1

msqid为队列ID。

ptr指向一个长整型数,长整型数后面的是消息数据。长整型数表示消息类型(为正),后面消息数据的长度由nbytes表明。如发送的最长消息为512字节,则可定义以下结构:

struct mymesg {
    long mtype;
    char mtext[512];
};

ptr就是一个指向mymesg结构的指针。

接收者可以使用消息类型(mtype字段)以非先进先出的次序取消息。

参数flag的值可以指定为IPC_NOWAIT。若消息队列已满(或者是队列中的消息总数等于系统限制值,或队列中的字节总数等于系统限制值),msgsnd立即出错返回EAGAIN。

如果没有指定IPC_NOWAIT,则进程会一直阻塞到:有空间可以容纳要发送的消息;或者从系统中删除了此队列(返回EIDRM错误);或者捕捉到一个信号,并从信号处理程序返回(返回EINTR错误)。

对删除消息队列的处理不是很完善。每个消息队列没有维护引用计数器(打开文件有这种计数器),所以在队列被删除以后,仍在使用这一队列的进程在下次对队列进行操作时会出错返回。

msgsnd返回成功时,消息队列相关的msqid_ds结构会随之更新,表明调用的进程ID(msg_lspid)、调用的时间(msg_stime)以及队列中新增的消息(msg_qnum)。

msgrcv用于从队列中取消息:

#include <sys/msg.h>

ssize_t msgrcv(int msqid, const void* ptr, size_t nbytes, long type, int flag);
// 成功返回消息数据的长度,出错返回-1

ptr参数指向一个长整型数(其中存储的是返回的消息类型),其后跟随的是存储实际消息数据的缓冲区。nbytes指定数据缓冲区的长度。

若返回的消息长度大于nbytes,而且在flag中设置了MSG_NOERROR位,则该消息会被截断。如果没有设置这一标志,而消息又太长,则出错返回E2BIG(消息仍留在队列中)。

参数type可以指定想要哪一种消息:

  • type == 0:返回队列中的第一个消息。
  • type > 0:返回队列中消息类型为type的第一个消息。
  • type < 0:返回队列中消息类型值小于等于type绝对值的消息,如果这种消息有若干个,则取类型值最小的消息。

flag值可以指定为IPC_NOWAIT,使操作不阻塞。如果没有所指定类型的消息可用,则msgrcv返回−1,error设置为ENOMSG。

如果没有指定IPC_NOWAIT,则进程会一直阻塞到有了指定类型的消息可用,或者从系统中删除了此队列(返回−1,error设置为EIDRM),或者捕捉到一个信号并从信号处理程序返回(这会导致msgrcv返回−1,errno设置为EINTR)。

msgrcv成功执行时,内核会更新与该消息队列相关联的msgid_ds结构,以指示调用者的进程ID(msg_lrpid)和调用时间(msg_rtime),并指示队列中的消息数减少了1个(msg_qnum)。

信号量

XSI信号量并非是单个非负值,而是含有一个或多个信号量的集合。创建信号量时,要制定集合中信号量的个数。

内核为每个信号量集合维护一个semid_ds结构:

struct semid_ds {
	struct ipc_perm sem_perm;	// 权限结构
    unsigned short sem_nsems;	// num of semaphores in set
    time_t sem_otime;			// last-semop() time
    time_t sem_ctime;			// last-change time
};

每个信号量由一个无名结构表示,至少包含以下成员:

struct {
	unsigned short semval;	// semaphore value, always >= 0
    pid_t sempid;			// pid for last operation
    unsigned short semncnt;	// num of processes awaiting semval > curval
    unsigned short semzcnt;	// num of processes awaiting semval == 0
};

可以通过semget函数来获得一个信号量ID:

#include <sys/sem.h>

int semget(key_t key, int nsems, int flag);
// 成功返回信号量ID,出错返回-1

创建一个新集合时,内核会这样初始化semid_ds结构:

  • icp_perm结构的初始化与消息队列类似。
  • sem_otime设置为0。
  • sem_ctime设置为当前时间。
  • sem_nsems设置为nsems参数。

信号量同样有一个垃圾桶函数semctl:

#include <sys/sem.h>

int semctl(int semid, int semnum, int cmd, .../* union semun arg */);
// 返回值见下方

union semun的结构如下:

union semun {
    int val;				// for SETVAL
    struct semid_ids* buf;	// for IPC_STAT and ICP_SET
    unsigned short* array;	// for GETALL and SETALL
};

cmd参数的可能取值:

  • IPC_STAT:对此集合取semid_ds结构,并存储在由arg.buf指向的结构中。
  • IPC_SET:按arg.buf指向的结构中的值,设置与此集合相关的结构中的sem_perm.uid、sem_perm.gid和sem_perm.mode字段。
  • IPC_RMID:从系统中删除该信号量集合。这种删除是立即发生的。删除时仍在使用此信号量集合的其他进程,在它们下次试图对此信号量集合进行操作时,将出错返回EIDRM。
  • GETVAL:返回成员semnum的semval值。
  • SETVAL:设置成员semnum的semval值。该值由arg.val指定。
  • GETPID:返回成员semnum的sempid值。
  • GETNCNT:返回成员semnum的semncnt值。
  • GETZCNT:返回成员semnum的semzcnt值。
  • GETALL:取该集合中所有的信号量值。这些值存储在arg.array指向的数组中。
  • SETALL:将该集合中所有的信号量值设置成arg.array指向的数组中的值。

ICP_SET和ICP_RMID的权限要求和消息队列中的心痛

对于除GETALL以外的所有GET命令,semctl函数都返回相应值。对于其他命令,若成功则返回值为0,若出错,则设置errno并返回−1。

函数semop自动执行信号量集合上的操作数组:

#include <sys/sem.h>

int semop(int semid, struct sembuf semoparray[], size_t nops);
// 返回值:若成功,返回0;若出错,返回−1

其中sembuf结构如下:

struct sembuf {
    unsigned short sem_num;	// 信号量的在信号量集合中序号,[0, nsems-1]
    short sem_op;			// operation(neg, 0, or pos)
    short sem_flg;			// ICP_NOWAIT, SEM_UNDO
};
  1. 若sem_op为正值。信号量的值加sem_op。如果指定了undo标志,则变为减去sem_op。

  2. 若sem_op为负值。若信号量值>=sem_op的绝对值,则信号量的值加sem_op(加一个负值就相当于减小)。如果指定了undo标志,则变为减去sem_op。若信号量值<sem_op的绝对值,则:

    • 若指定了IPC_NOWAIT,则semop出错返回EAGAIN。

    • 若未指定IPC_NOWAIT,则信号量的semncnt值加1,然后调用进程被挂起直至下列事件之一发生:

      • 此信号量值变成大于等于sem_op的绝对值。然后信号量的semncnt值减1,并且从信号量值中减去sem_op的绝对值。如果指定了undo标志,变为加。
      • 从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。
      • 进程捕捉到一个信号,并从信号处理程序返回,在这种情况下,此信号量的semncnt值减1,并且函数出错返回EINTR。
  3. 若sem_op为0,这表示调用进程希望等待到该信号量值变成0。

    • 如果信号量值当前是0,则此函数立即返回。
    • 如果信号量值非0,则:
      • 若指定了IPC_NOWAIT,则出错返回EAGAIN。
      • 若未指定IPC_NOWAIT,则该信号量的semzcnt加1,然后调用进程被挂起,直至下列的一个事件发生:
        • 此信号量值变成0。此信号量的semzcnt值减1。
        • 从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。
        • 进程捕捉到一个信号,并从信号处理程序返回。在这种情况下,此信号量的semzcnt值减1,并且函数出错返回EINTR。

semop函数具有原子性,它或者执行数组中的所有操作,或者一个也不做。

共享存储

XSI共享存储和内存映射的文件的不同之处在于,共享存储没有相关的文件。XSI共享存储段是内存的匿名段。

内核为每个共享内存段维护着一个shmid_ds结构:

struct shmid_ids {
    struct ipc_perm shm_perm;	/* 权限结构 */
    size_t shm_segsz;			/* size of segment in bytes */
    pid_t shm_lpid;				/* pid of last shmop() */
    pid_t  shm_cpid;			/* pid of creator */
    shmatt_t shm_nattch;		/* number of current attaches */
    time_t shm_atime;			/* last-attach time */
    time_t shm_dtime;			/* last-detach time */
    time_t shm_ctime;			/* last-change time */
};

可以通过shmget函数来获取一个共享存储标识符。

#include <sys/shm.h>

int shmget(key_t key, size_t size, int flag);
// 成功返回共享存储ID,出错返回-1

创建一个新共享存储段时,内核会这样初始化shmid_ds结构:

  • icp_perm结构的初始化与消息队列类似。
  • shm_lpid、shm_nattach、shm_atime和shm_dtime都设置为0。
  • sem_ctime设置为当前时间。
  • shm_segsz设置为size参数。

参数size是该共享存储段的长度,以字节为单位。实现通常将其向上取为系统页长的整倍数。但是,若应用指定的size值并非系统页长的整倍数,那么最后一页的余下部分是不可使用的。

如果正在创建一个新段(通常在服务器进程中),则必须指定其size。如果正在引用一个现存的段(一个客户进程),则将size指定为0。当创建一个新段时,段内的内容初始化为0。

共享存储同样有一个垃圾桶函数shmctl:

#include <sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds* buf);
// 成功返回0,出错返回-1

cmd参数的可能值:

  • IPC_STAT:取此段的shmid_ds结构,并将它存储在由buf指向的结构中。

  • IPC_SET:按buf指向的结构中的值设置与此共享存储段相关的shmid_ds 结构中的下列3个字段:shm_perm.uid、shm_perm.gid和shm_perm.mode。

  • IPC_RMID:从系统中删除该共享存储段。因为每个共享存储段维护着一个连接计数(shmid_ds结构中的shm_nattch字段),所以除非使用该段的最后一个进程终止或与该段分离,否则不会实际上删除该存储段。不管此段是否仍在使用,该段标识符都会被立即删除,所以不能再用shmat与该段连接。

  • SHM_LOCK:在内存中对共享存储段加锁。此命令只能由超级用户执行。

  • SHM_UNLOCK:解锁共享存储段。此命令只能由超级用户执行。

IPC_SET和ICP_RMID的权限要求与之前相同。

最后两个命令并非SUS的组成部分。Linux和Solaris提供了这两个命令。

一旦创建了一个共享存储段,进程就可调用shmat将其连接到它的地址空间中。

#include <sys/shm.h>

void* shmat(int shmid, const void* addr, int flag);
// 成功返回指向共享存储段的指针,出错返回-1

共享存储段连接到调用进程的哪个地址上与addr参数以及flag中是否指定SHM_RND位有关:

  • 如果addr为0,则此段连接到由内核选择的第一个可用地址上。这是推荐的使用方式。
  • 如果addr非0,并且没有指定SHM_RND,则此段连接到addr所指定的地址上。
  • 如果addr非0,并且指定了SHM_RND,则此段连接到(addr−(addr mod SHMLBA))所表示的地址上。SHM_RND命令的意思是“取整”。SHMLBA的意思是“低边界地址倍数”,它总是2的乘方。该算式是将地址向下取最近1个SHMLBA的倍数。

如果在flag中指定了SHM_RDONLY位,则以只读方式连接此段,否则以读写方式连接此段。

当对共享存储段的操作已经结束时,则调用shmdt与该段分离。注意,这并不从系统中删除其标识符以及其相关的数据结构。该标识符仍然存在,直至某个进程(一般是服务器进程)带IPC_RMID命令的调用shmctl特地删除它为止。

#include <sys/shm.h>

int shmdt(const void* addr);
// 成功返回0,出错返回-1

如果成功,shmdt将使相关shmid_ds结构中的shm_nattch计数器值减1。

POSIX信号量

POSIX信号量有两种形式:命名的和未命名的。它们的差异在于创建和销毁的形式上,但其他工作一样。

  • 未命名信号量只存在于内存中,并要求能使用信号量的进程必须可以访问内存。这意味着它们只能应用在同一进程中的线程,或者不同进程中已经映射相同内存内容到它们的地址空间中的线程。

  • 命名信号量可以通过名字访问,可以被任何已知它们名字的进程中的线程使用。

我们可以调用sem_open函数来创建一个新的命名信号量或者使用一个现有信号量。

#include <semaphore.h>

sem_t* sem_open(const char* name, int oflag, ... /* mode_t mode, unsigned int value */ );
// 成功返回指向信号量的指针,出错返回SEM_FAILED

当使用一个现有的命名信号量时,仅需指定信号量的名字,并将oflag设置为0。

当oflag参数有O_CREAT标志集时,如果命名信号量不存在,则创建一个新的。如果它已经存在,则会被使用,但是不会有额外的初始化发生。

当我们指定O_CREAT标志时,需要提供两个额外的参数。mode参数指定谁可以访问信号量。mode的取值和打开文件的权限位相同:用户读、用户写、用户执行、组读、组写、组执行、其他读、其他写和其他执行。赋值给信号量的权限可以被调用者的文件创建屏蔽字修改。

在创建信号量时,value参数用来指定信号量的初始值。它的取值是0~SEM_VALUE_MAX。

如果我们想确保创建的是信号量,可以设置oflag参数为O_CREAT | O_EXCL。如果信号量已经存在,会导致sem_open失败。

信号量命名规范:

  • 名字的第一个字符应该为斜杠(/)。
  • 名字不应包含其他斜杠以此避免实现定义的行为。
  • 信号量名字的最大长度是实现定义的。名字不应该长于_POSIX_NAME_MAX。

可以调用sem_close函数来释放信号量相关的资源:

#include <semaphore.h>

int sem_close(sem_t* sem);
// 成功返回0,出错返回-1

如果进程没有首先调用sem_close而退出,那么内核将自动关闭任何打开的信号量。

可以使用sem_unlink函数来销毁一个命名信号量。

#include <semaphore.h>

int sem_unlink(const char *name);
// 返回值:若成功,返回0;若出错,返回-1

sem_unlink函数删除信号量的名字。如果没有打开的信号量引用,则该信号量会被销毁。否则,销毁将延迟到最后一个打开的引用关闭。

可以使用sem_wait或者sem_trywait函数来实现信号量的减1操作(即P操作)。

#include <semaphore.h>

int sem_trywait(sem_t* sem);
int sem_wait(sem_t* sem);
// 成功返回0,出错返回−1

使用sem_wait函数时,如果信号量计数是0就会阻塞。直到成功使信号量减1或者被信号中断时才返回。

调用sem_trywait时,如果信号量是0,则不会阻塞,而是会返回−1并且将errno置为EAGAIN。

还可以使用sem_timedwait函数来指定最大等待时间:

#include <semaphore.h>
#include <time.h>

int sem_timedwait(sem_t* restrict sem, const struct timespec* restrict tsptr);
// 返回值:若成功,返回0;若出错,返回−1

如果超时到期并且信号量计数没能减1,sem_timedwait将返回-1且将errno设置为ETIMEDOUT。

可以调用sem_post函数使信号量值增1(即V操作):

#include <semaphore.h>

int sem_post(sem_t* sem);
// 成功返回0,出错返回−1

调用sem_post时,如果在调用sem_wait(或者sem_timedwait)中发生进程阻塞,那么进程会被唤醒并且被sem_post增1的信号量计数会再次被sem_wait(或者sem_timedwait)减1。

可以调用sem_init函数来创建一个未命名的信号量。

#include <semaphore.h>

int sem_init(sem_t* sem, int pshared, unsigned int value);
// 成功返回0,出错返回−1

pshared参数表明是否在多个进程中使用信号量。如果是,需要设置成一个非0值。

value参数指定了信号量的初始值。

可以调用sem_destroy函数销毁未命名的信号量:

#include <semaphore.h>

int sem_destroy(sem_t* sem);
// 成功返回0,出错返回−1

可以使用sem_getvalue函数来检索信号量值:

#include <semaphore.h>

int sem_getvalue(sem_t *restrict sem, int *restrict valp);
// 成功返回0,出错返回−1

调用成功后,valp指向的整数值将包含信号量值。

但是我们试图要使用我们刚读出来的值的时候,信号量的值可能已经变了。除非使用额外的同步机制来避免这种竞争,否则sem_getvalue函数只能用于调试。

用信号量来实现自己的锁结构:

// slock.h
#include <semaphore.h>
#include <fcntl.h>
#include <limits.h>
#include <sys/stat.h>

struct slock {
    sem_t* semp;
    char name[_POSIX_NAME_MAX];
};

struct slock* s_alloc();
void s_free(struct slock*);
int s_lock(struct slock*);
int s_trylock(struct slock*);
int s_unlock(struct slock*);
#include "slock.h"
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

struct slock* s_alloc() {
    struct slock* sp;
    static int cnt;
    if ((sp = malloc(sizeof(struct slock))) == NULL) {
        return NULL;
    }
    do {
        snprintf(sp->name, sizeof(sp->name), "/%ld.%d", (long)getpid(), cnt++);
        sp->semp = sem_open(sp->name, O_CREAT | O_EXCL, S_IRWXU, 1);
    } while ((sp->semp == SEM_FAILED) && (errno == EEXIST));
    if (sp->semp == SEM_FAILED) {
        free(sp);
        return NULL;
    }
    sem_unlink(sp->name);
    return sp;
}

void s_free(struct slock* sp) {
    sem_close(sp->semp);
    free(sp);
}

int s_lock(struct slock* sp) {
    return sem_wait(sp->semp);
}

int s_trylock(struct slock* sp) {
    return sem_trywait(sp->semp);
}

int s_unlock(struct slock* sp) {
    return sem_post(sp->semp);
}

网络IPC:套接字

套接字描述符

套接字描述符在UNIX系统中被当作是一种文件描述符。许多处理文件描述符的函数(如read和write)可以用于处理套接字描述符。

可以用socket函数来创建一个套接字:

#include <sys/socket.h>

int socket(int domain, int type, int protocol);
// 成功返回套接字描述符,出错返回-1

其中domain的可选值有:

image-20210512182404406

AF表示地址族(address family)

参数type确定套接字的类型,有如下可选值:

image-20210512182534400

参数protocol的可能值有:

image-20210512182648987

参数protocol通常是0,表示为给定的域和套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用protocol选择一个特定协议。

数据报(SOCK_DGRAM)提供无连接服务。

字节流(SOCK_STREAM)提供字节流服务(有连接的),应用程序分辨不出报文的界限。从SOCK_STREAM套接字读数据时,它也许不会返回所有由发送进程所写的字节数。要获得发送过来的所有数据,可能需要经过多次函数调用。

SOCK_DGRAM的默认协议是UDP,SOCK_STREAM的默认协议是TCP。

SOCK_SEQPACKET套接字和SOCK_STREAM套接字很类似,只是从该套接字得到的是基于报文的服务而不是字节流服务。也就是说从SOCK_SEQPACKET套接字接收的数据量与对方所发送的一致。

SOCK_RAW套接字提供一个数据报接口,用于直接访问下面的网络层(即因特网域中的 IP层)。使用这个接口时,应用程序负责构造自己的协议头部,因为传输协议(如TCP和UDP)被绕过了。

当创建一个原始套接字时,需要有超级用户特权。

常见I/O函数对套接字描述符的支持:

image-20210512183520951

套接字通信是双向的。可以采用shutdown函数来禁止一个套接字的I/O。

#include <sys/socket.h>

int shutdown(int sockfd, int how);
// 成功返回0,出错返回−1
  • 如果how是SHUT_RD(关闭读端),那么无法从套接字读取数据。
  • 如果how是SHUT_WR(关闭写端),那么无法使用套接字发送数据。
  • 如果how是SHUT_RDWR,则既无法读取数据,又无法发送数据。

寻址

字节序

大端字节序:最高有效字节的字节地址最低。

小端字节序:最低有效字节的字节地址最低。

TCP/IP协议栈使用大端字节序。

4个用来在处理器字节序和网络字节序中转换的函数:

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostint32);
// 返回以网络字节序表示的32位整数
uint16_t htons(uint16_t hostint16);
// 返回以网络字节序表示的16位整数
uint32_t ntohl(uint32_t netint32);
// 返回以主机字节序表示的32位整数
uint16_t ntohs(uint16_t netint16);
// 返回以主机字节序表示的16位整数

h代表主机host,n代表网络network,l代表长long,s代表短short。

地址格式

一个地址标识一个特定通信域的套接字端点。

为使不同格式地址能够传入到套接字函数,地址会被强制转换成一个通用的地址结构sockaddr:

struct sockaddr {
    sa_family_t sa_family;  // address family: AF_INET, AF_INET6, ...
    char sa_data[];         // variable-length address
};

套接字的实现可以自由地添加额外的成员并且定义sa_data成员的大小。

因特网地址定义在<netinet/in.h>头文件中。IPv4中套接字地址用结构sockaddr_in表示:

struct in_addr {
    in_addr_t s_addr;   // IPv4 address
};

struct sockaddr_in {
    sa_family_t sin_family; // address family
    in_port_t sin_port;     // port number
    struct in_addr sin_addr;// IPv4 address
};

而IPv6中使用结构sockaddr_in6表示:

struct in6_addr {
    uint8_t s6_addr[16];    // IPv6 address
};

struct sockaddr_in6 {
    sa_family_t sin6_family;    // address family
    in_port_t sin6_port;        // port number
    uint32_t sin_flowinfo;      // traffic class and flow info
    struct in6_addr sin6_addr;  // IPv6 address
    uint32_t sin6_scope_id;     // set of interfaces for scope
};

这些都是SUS的定义,具体实现可以自由添加更多的字段。

尽管sockaddr_in与sockaddr_in6结构相差比较大,但它们均被强制转换成sockaddr结构输入到套接字例程中。

套接字二进制地址格式和点分十进制字符表示之间的相互转换:

#include <arpa/inet.h>

const char *inet_ntop(int domain, const void *restrict addr, char *restrict str, socklen_t size);
// 成功返回地址字符串指针,出错返回NULL
int inet_pton(int domain, const char *restrict str, void *restrict addr);
// 成功返回1,格式无效返回0,出错返回-1
  • inet_ntop将网络字节序的二进制地址转换成文本字符串格式。
  • inet_pton将文本字符串格式转换成网络字节序的二进制地址。

参数domain仅支持两个值:AF_INET和AF_INET6。

参数size通常取INET_ADDRSTRLEN来存放一个表示IPv4地址的文本字符串;取INET6_ADDRSTRLEN来存放一个表示IPv6地址的文本字符串。

地址查询

可以调用gethostent函数来查找给定计算机系统的主机信息:

#include <netdb.h>

struct hostent *gethostent(void);
// 成功返回指针,出错返回NULL
void sethostent(int stayopen);
void endhostent(void);

如果主机数据库文件没有打开,gethostent会打开它。函数gethostent返回文件中的下一个条目。

函数sethostent会打开文件,如果文件已经被打开,那么将其回绕。当stayopen参数设置成非0值时,调用gethostent之后,文件将依然是打开的。

不太明白回绕是什么意思。

函数endhostent可以关闭文件。

当gethostent返回时,会得到一个指向hostent结构的指针,该结构可能包含一个静态的数据缓冲区,每次调用gethostent,缓冲区都会被覆盖

其中struct hostent结构至少包含以下成员:

struct hostent {
	char *h_name;		// name of host
	char **h_aliases;	// pointer to alternate host name array
	int h_addrtype;		// address type
	int h_length;		// length in bytes of address
	char **h_addr_list;	// pointer to array of network addresses
}

返回的地址按照网络字节序。地址类型(h_addrtype)是AF_INET系列常量。

可以使用以下函数来获取网络名和网络编号:

#include <netdb.h>

struct netent *getnetbyaddr(uint32_t net, int type);
struct netent *getnetbyname(const char *name);
struct netent *getnetent(void);
// 成功返回指针,出错返回NULL
void setnetent(int stayopen);
void endnetent(void);

其中netent的结构至少包含以下字段:

struct netent {
    char *n_name;       // network name
    char **n_aliases;	// alternate network name array pointer
    int n_addrtype;		// address type
    uint32_t n_net;		// network number
};

网络编号按照网络字节序返回。地址类型是地址族常量之一(如AF_INET)。

可以使用以下函数在协议名字和协议编号之间进行映射:

#include <netdb.h>

struct protoent *getprotobyname(const char *name);
struct protoent *getprotobynumber(int proto);
struct protoent *getprotoent(void);
// 成功返回指针,出错返回NULL
void setprotoent(int stayopen);
void endprotoent(void);

其中protoent结构至少包含以下成员:

struct protoent {
    char *p_name;       // protocol name
    char **p_aliases;   // pointer to alternate protocol name array
    int p_proto;        // protocol number
};

服务是由地址的端口号部分表示的。每个服务由一个唯一的众所周知的端口号来支持。可以使用函数getservbyname将一个服务名映射到一个端口号,使用函数getservbyport将一个端口号映射到一个服务名,使用函数getservent顺序扫描服务数据库。

#include <netdb.h>

struct servent *getservbyname(const char *name, const char *proto);
struct servent *getserbyport(int port, const char *proto);
struct servent *getservent(void);
// 成功返回指针,出错返回NULL
void setservent(int stayopen);
void endservent(void);

其中servent结构至少包含以下成员:

struct servent{
    char *s_name;		/* service name */
    char **s_aliases; 	/* pointer to alternate service name array */
    int s_port;			/* port number */
    char *s_proto; 		/* name of protocol */
};

可以使用getaddrinfo函数将一个主机名和一个服务名映射到一个地址:

#include <sys/socket.h>
#include <netdb.h>

int getaddrinfo(const char *restrict host, const char *restrict sevice, const struct addrinfo *restrict hint, struct addrinfo **restrict res);
// 成功返回0,出错返回非0错误码
void freeaddrinfo(struct addrinfo *ai);

getaddrinfo函数返回一个链表结构addrinfo。

其中addrinfo结构至少包含以下成员:

struct addrinfo {
    int ai_flags;           	// customize behavior
    int ai_family;				// address family
    int ai_socktype;			// socket type
    int ai_protocol;			// protocol
    socklen_t ai_addrlen;		// length in bytes of address
    struct sockaddr *ai_addr;	// address
    char *ai_cannoname;			// canonical name of host
    struct addrinfo *ai_next;	// next in list
};

freeaddrinfo可以释放一个或多个这种结构,这取决于用ai_next字段链接起来的结构有多少。

hint是一个用于过滤地址的模板,包括ai_family、ai_flags、ai_protocol和ai_socktype字段。剩余的整数字段必须设置为0,指针字段必须为空。

其中flags的可选值有:

image-20210512220244767

如果getaddrinfo失败,需要使用gai_strerror函数将返回的错误码转换为错误消息:

#include <netdb.h>

const char *gai_strerror(int error);
// 返回指向描述错误的字符串的指针

getnameinfo函数将一个地址转换成一个主机名和一个服务名:

#include <sys/socket.h>
#include <netdb.h>

int getnameinfo(const struct sockaddr *restrict addr, socklen_t alen, char *restrict host, socklen_t hostlen, char *restrict service, socklen_t servlen, int flags);
// 成功返回0,出错返回非0值

套接字地址(addr)被翻译成一个主机名和一个服务名。

如果host非空,则指向一个长度为hostlen字节的缓冲区用于存放返回的主机名。

如果service非空,则指向一个长度为servlen字节的缓冲区用于存放返回的主机名。

其中flags参数的可选值如下:

image-20210512220753566

一个示例程序:

#include "apue.h"
#if defined(SOLARIS)
#include <netinet/in.h>
#endif
#include <netdb.h>
#include <arpa/inet.h>
#if defined(BSD)
#include <sys/socket.h>
#include <netinet/in.h>
#endif

void print_family(struct addrinfo* aip) {
    printf(" family ");
    switch (aip->ai_family) {
        case AF_INET:
            printf("inet");
            break;
        case AF_INET6:
            printf("inet6");
            break;
        case AF_UNIX:
            printf("unix");
            break;
        case AF_UNSPEC:
            printf("unspecified");
            break;
        default:
            printf("unknown");
    }
}

void print_type(struct addrinfo* aip) {
    printf(" type ");
    switch (aip->ai_socktype) {
        case SOCK_STREAM:
            printf("stream");
            break;
        case SOCK_DGRAM:
            printf("datagram");
            break;
        case SOCK_SEQPACKET:
            printf("seqpacket");
            break;
        case SOCK_RAW:
            printf("raw");
            break;
        default:
            printf("unknown (%d)", aip->ai_socktype);
    }
}

void print_protocol(struct addrinfo* aip) {
    printf(" protocol ");
    switch (aip->ai_protocol) {
        case 0:
            printf("default");
            break;
        case IPPROTO_TCP:
            printf("TCP");
            break;
        case IPPROTO_UDP:
            printf("UDP");
            break;
        case IPPROTO_RAW:
            printf("raw");
            break;
        default:
            printf("unknow (%d)", aip->ai_protocol);
    }
}

void print_flags(struct addrinfo* aip) {
    printf("flags");
    if (aip->ai_flags == 0) {
        printf(" 0");
    } else {
        if (aip->ai_flags & AI_PASSIVE) {
            printf(" passive");
        }
        if (aip->ai_flags & AI_CANONNAME) {
            printf(" canon");
        }
        if (aip->ai_flags & AI_NUMERICHOST) {
            printf(" numhost");
        }
        if (aip->ai_flags & AI_NUMERICSERV) {
            printf(" numserv");
        }
        if (aip->ai_flags & AI_V4MAPPED) {
            printf(" v4mapped");
        }
        if (aip->ai_flags & AI_ALL) {
            printf(" all");
        }
    }
}

int main(int argc, char* argv[]) {
    struct addrinfo *ailist, *aip;
    struct addrinfo hint;
    struct sockaddr_in *sinp;
    const char *addr;
    int err;
    char abuf[INET_ADDRSTRLEN];

    if (argc != 3) {
        err_quit("usage: %s <nodename> <service>", argv[0]);
    }
    hint.ai_flags = AI_CANONNAME;
    hint.ai_family = 0;
    hint.ai_socktype = 0;
    hint.ai_protocol = 0;
    hint.ai_addrlen = 0;
    hint.ai_canonname = NULL;
    hint.ai_addr = NULL;
    hint.ai_next = NULL;
    if ((err = getaddrinfo(argv[1], argv[2], &hint, &ailist)) != 0) {
        err_quit("getaddrinfo error: %s", gai_strerror(err));
    }
    for (aip = ailist; aip != NULL; aip = aip->ai_next) {
        print_flags(aip);
        print_family(aip);
        print_protocol(aip);
        printf("\n\thost %s", aip->ai_canonname ? aip->ai_canonname : "-");
        if (aip->ai_family == AF_INET) {
            sinp = (struct sockaddr_in*)aip->ai_addr;
            addr = inet_ntop(AF_INET, &sinp->sin_addr, abuf, INET_ADDRSTRLEN);
            printf(" address %s", addr ? addr : "unknown");
            printf(" port %d", ntohs(sinp->sin_port));
        }
        printf("\n");
    }
    exit(0);
}

将套接字与地址关联

使用bind函数来将地址和套接字关联起来:

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t len);
// 成功返回0,出错返回-1

如果调用connect或listen时,还没有将地址绑定到套接字上,系统会选一个地址绑定到套接字上。

可以调用getsockname函数来获取绑定到套接字上的地址:

#include <sys/socket.h>

int getsockname(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict alenp);
// 成功返回0,出错返回-1

调用getsockname之前,alenp指向整数指定缓冲区sockaddr的长度。返回时,该整数会被设置成返回地址的大小。如果地址和提供的缓冲区长度不匹配,地址会被自动截断而不报错。如果当前没有地址绑定到该套接字,则其结果是未定义的。

如果套接字已经和对等方连接,可以调用getpeername函数来找到对方的地址。

#include <sys/socket.h>

int getpeername(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict alenp);
// 成功返回0,出错返回-1

建立连接

在处理一个面向连接的网络服务(SOCK_STREAM或SOCK_SEQPACKET)的时候,需要在请求服务的进程套接字(客户端)和提供服务的进程套接字(服务器)之间建立一个连接。

使用connect函数来建立连接:

#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t len);
// 成功返回0,出错返回-1

addr参数指定我们想要与之通信的服务器地址。

如果sockfd此时没有绑定到一个地址,connect会给调用者绑定一个默认地址。

connect函数还可以用于无连接的网络服务(SOCK_DGRAM)。如果用SOCK_DGRAM套接字调用connect,传送的报文的目标地址会设置成connect调用中所指定的地址,这样每次传送报文时就不需要再提供地址。并且仅能接收来自指定地址的报文。

服务器调用listen函数来宣告它愿意接受连接请求:

#include <sys/socket.h>

int listen(int sockfd, int backlog);
// 成功返回0,出错返回-1

backlog参数指定了该进程所要入队的未完成连接请求数量。队列满了之后,系统就会拒绝多余的连接请求。

调用了listen后套接字就能接收连接请求。可以使用accept函数获得连接请求并建立连接。

#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict len);
// 成功返回文件(套接字)描述符,出错返回−1

返回的文件描述符是套接字描述符,该描述符连接到调用connect的客户端。这个新的套接字描述符和原始套接字(sockfd)具有相同的套接字类型和地址族。传给accept的原始套接字没有关联到这个连接,而是继续保持可用状态并接收其他连接请求。

返回时,accept会将addr设置为客户端的地址,并且更新指向len的整数来反映该地址的大小。不关心这两个参数可将两者设置为NULL。

如果没有连接请求在等待,accept会阻塞直到一个请求到来。如果sockfd处于非阻塞模式,accept会返回−1,并将errno设置为EAGAIN或EWOULDBLOCK。

数据传输

有3个用来发送数据的函数。

#include <sys/socket.h>

ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
// 成功返回发送的字节数,出错返回-1

使用send时,套接字必须已经连接。

buf参数和nbytes参数与write函数中相同。

flags参数的可选值如下:

image-20210512223610727

即使send成功返回,也并不表示连接的另一端的进程就一定接收了数据。我们所能保证的只是当send成功返回时,数据已经被无错误地发送到网络驱动程序上。

对于支持报文边界的协议,如果尝试发送的单个报文的长度超过协议所支持的最大长度,那么send会失败,并将errno设为EMSGSIZE。对于字节流协议,send会阻塞直到整个数据传输完成。

sendto在send的基础上可以指定一个目标地址(用于无连接的套接字):

#include <sys/socket.h>

ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags, const struct sockaddr *destaddr, socklen_t destlen);
// 成功返回发送的字节数,出错返回-1

对于面向连接的套接字,目标地址会被忽略。

sendmsg函数类似于writev函数:

#include <sys/socket.h>

ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
// 成功返回发送的字节数,出错返回-1

其中msghdr的结构如下:

struct msghdr {
    void *msg_name;				// optional address
    socklen_t msg_namelen;		// address size in bytes
    struct iovec *msg_iov;		// array of I/O buffers
    int msg_iovlen;				// number of elements in array
    void *msg_control;			// ancillary data, 辅助数据
    socklen_t msg_controllen;	// number of ancillary data
    int msg_flags;  
};

有三个用来接受数据的函数:

recv函数类似于read函数:

#include <sys/socket.h>

ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
// 返回数据的字节长度,若无可用数据或对等方已经按序结束则返回0,出错返回-1

其中flags参数的可选值有:

image-20210512225054807

当指定MSG_PEEK标志时,可以查看下一个要读取的数据但不真正取走它。当再次调用read或其中一个recv函数时,会返回刚才查看的数据。

对于SOCK_STREAM套接字,接收的数据可以比预期的少。MSG_WAITALL标志会阻止这种行为,直到所请求的数据全部返回,recv函数才会返回。对于SOCK_DGRAM和SOCK_SEQPACKET套接字,MSG_WAITALL 标志没有改变什么行为,因为这些基于报文的套接字类型一次读取就返回整个报文。

如果发送者已经调用shutdown来结束传输,或者网络协议支持按默认的顺序关闭并且发送端已经关闭,那么当所有的数据接收完毕后,recv返回0。

可以使用recvfrom函数来得到数据发送者的源地址:

#include <sys/socket.h>

ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags, struct sockaddr *restrict addr, socklen_t *restrict addrlen);
// 返回数据的字节长度,若无可用数据或对等方已经按序结束则返回0,出错返回-1

recvmsg函数类似于readv函数:

#include <sys/socket.h>

ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
// 返回数据的字节长度,若无可用数据或对等方已经按序结束则返回0,出错返回-1

套接字选项

可以使用setsockopt函数来设置套接字选项。

#include <sys/socket.h>

int setsockopt(int sockfd, int option, const void *val, socklen_t len);
// 成功返回0,出错返回-1

参数level标识了选项应用的协议。如果选项是通用的套接字层次选项,则level设置成SOL_SOCKET。否则,level设置成控制这个选项的协议编号。对于TCP选项,level是IPPROTO_TCP,对于IP,level是IPPROTO_IP。

option参数的可选值如下:

image-20210512230038747

参数val根据选项的不同指向一个数据结构或者一个整数。一些选项是on/off开关。如果整数非0,则启用选项。如果整数为0,则禁止选项。参数len指定了val指向的对象的大小。

可以使用getsockopt函数来获取选项的当前值:

#include <sys/socket.h>

int getsockopt(int sockfd, int level, int option, void *restrict val, socklen_t *restric lenp);
// 成功返回0,出错返回-1

参数lenp是一个指向整数的指针。在调用getsockopt之前,设置该整数为复制选项缓冲区的长度。如果选项的实际长度大于此值,则选项会被截断。如果实际长度正好小于此值,那么返回时将此值更新为实际长度。

带外数据

带外数据(out-of-band data)是一些通信协议所支持的可选功能,与普通数据相比,它允许更高优先级的数据传输。带外数据先行传输,即使传输队列已经有数据。

TCP支持带外数据,但是UDP不支持。

TCP将带外数据称为紧急数据(urgent data)。TCP仅支持一个字节的紧急数据,但是允许紧急数据在普通数据传递机制数据流之外传输。

为了产生紧急数据,可以在3个send函数中的任何一个里指定MSG_OOB标志。如果带MSG_OOB标志发送的字节数超过一个时,最后一个字节将被视为紧急数据字节。

如果通过套接字安排了信号的产生,那么紧急数据被接收时,会发送SIGURG信号。

可以通过调用以下函数安排进程接收套接字的信号:

fcntl(sockfd, F_SETOWN, pid);

TCP支持紧急标记(urgent mark)的概念,即在普通数据流中紧急数据所在的位置。如果采用套接字选项SO_OOBINLINE,那么可以在普通数据中接收紧急数据。

可以使用函数sockatmark来判断是否已经到达紧急标记:

#include <sys/socket.h>

int sockatmark(int sockfd);
// 返回值:若在标记处,返回1;若没在标记处,返回0;若出错,返回−1

当下一个要读取的字节在紧急标志处时,sockatmark返回1。

非阻塞和异步I/O

在基于套接字的异步I/O中,启动异步I/O有两个步骤:

  1. 建立套接字所有权,这样信号可以被传递到合适的进程。
  2. 通知套接字当I/O操作不会阻塞时发信号。

可以使用3种方式来完成第一个步骤。

  1. 在fcntl中使用F_SETOWN命令。
  2. 在ioctl中使用FIOSETOWN命令。
  3. 在ioctl中使用SIOCSPGRP命令。

可以使用2种方式完成第二个步骤。

  1. 在fcntl中使用F_SETFL命令并且启用文件标志O_ASYNC。
  2. 在ioctl中使用FIOASYNC命令。

高级进程间通信

本章偏向于实践,所以书中很多内容不记录到笔记中。我目前掌握这些代码还是有些困难的😭。

UNIX域套接字

UNIX域套接字用于在同一台计算机上运行的进程之间的通信。虽然因特网域套接字可用于同一目的,但 UNIX域套接字的效率更高。

UNIX域套接字提供流和数据报两种接口。UNIX域数据报服务是可靠的,既不会丢失报文也不会传递出错。

可以使用socketpair函数来创建一对无命名的、相互连接的UNIX域套接字:

#include <sys/socket.h>

int socketpair(int domain, int type, int protocol, int sockfd[2]);
// 成功返回0,出错返回-1

一对相互连接的UNIX域套接字可以起到全双工管道的作用:两端对读和写开放。

image-20210513203943007

命名UNIX域套接字

UNIX域套接字的地址由sockaddr_un结构表示。

该结构的格式和具体实现相关。其中Linux3.2.0的表示为:

struct sockaddr_un {
    sa_family_t sun_family; // AF_UNIX
    char sun_path[108];     // pathname
};

sockaddr_un结构的sun_path成员包含一个路径名。当我们将一个地址绑定到一个UNIX域套接字时,系统会用该路径名创建一个S_IFSOCK类型的文件。

利用该地址和bind函数,我们就能创建一个命名的UNIX域套接字。

该文件仅用于向客户进程告示套接字名字。该文件无法打开,也不能由应用程序用于通信。

如果我们试图绑定同一地址时,该文件已经存在,那么bind请求会失败。当关闭套接字时,并不自动删除该文件,所以必须确保在应用程序退出前,对该文件执行解除链接操作。

使用示例:

#include "apue.h"
#include <sys/socket.h>
#include <sys/un.h>

int main() {
    int fd, size;
    struct sockaddr_un un;
    un.sun_family = AF_UNIX;
    strcpy(un.sun_path, "foo.socket");
    if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) {
        err_sys("socket failed");
    }
    size = offsetof(struct sockaddr_un, sun_path) + strlen(un.sun_path);
    // offsetof is a macro: 
    // #define offsetof(TYPE, MEMBER) ((int)&((TYPE *)0)->MEMBER)
    if (bind(fd, (struct sockaddr*)&un, size) < 0) {
        err_sys("bind failed");
    }
    printf("UNIX domain socket bound\n");
    exit(0);
}

getopt

可以利用getopt函数来处理命令行选项。

之前看到过一篇写的还不错的博客,可以一起参考。

#include <unistd.h>

int getopt(int argc, char *const argv[], const char *options);
// 若所有选项被处理完则返回-1,否则返回下一个选项字符
extern int optind, opterr, optopt;
extern char *optarg;

参数argc和argv与传入main函数的一样。

options参数是一个包含该命令支持的选项字符的字符串。如果一个选项字符后面接了一个冒号,则表示该选项需要参数;否则,该选项不需要额外参数。

当遇到无效的选项时,getopt返回一个问题标记(应该就是指?这个字符)而不是这个字符。如果选项缺少参数,getopt也会返回一个问题标记,但如果选项字符串的第一个字符是冒号,getopt会直接返回冒号。

特殊的“–”格式则会导致getopt停止处理选项并返回-1。这允许用户传递以“-”开头但不是选项的参数。

例如,如果有一个名字为“-bar”的文件,下面的命令行是无法删除这个文件的:

rm –bar

因为rm会试图把-bar解释为选项。正确的删除文件的命令应该是:

rm -- -bar

getopt函数支持以下4个外部变量。

  • optarg:如果一个选项需要参数,在处理该选项时,getopt会设置optarg指向该选项的参数字符串。

  • opterr:如果一个选项发生了错误,getopt会默认打印一条出错消息。应用程序可以通过设置opterr参数为0来禁止这个行为。

  • optind:用来存放下一个要处理的字符串在argv数组里的下标。它从1开始,每处理一个参数,getopt都会对其递增1。

  • optopt:如果处理选项时发生了错误,getopt会设置optopt指向导致出错的选项字符串。

截取的一部分示例程序:

while ((c = getopt(argc, argv, "d")) != EOF) {
    switch (c) {
        case "d":
            debug = 1;
            break;
        case '?':
            err_quit("unrecognized option -%c", optopt);
    }
}

一些更详细的示例可以参考我刚刚分享的博客。


分享:

低价透明

统一报价,无隐形消费

金牌服务

一对一专属顾问7*24小时金牌服务

信息保密

个人信息安全有保障

售后无忧

服务出问题客服经理全程跟进