第一章 系统调用

在这里插入图片描述
内核提供了一系列的服务、资源、支持一系列功能,应用程序通过调用系统调用 API 函数来使用内核提供的服务、资源以及各种各样的功能

1.2 库函数

库函数也就是 C 语言库函数
在 Linux 下,通常以动态(.so) 库文件的形式提供,存放在根文件系统/lib 目录

  • ​库函数是属于应用层,而系统调用是内核提供给应用层的编程接口,属于系统内核的一部分
    库函数运行在用户空间,调用系统调用会由用户空间(用户态)陷入到内核空间(内核态)
    库函数通常是有缓存的,而系统调用是无缓存的,所以在性能、效率上,库函数通常要优于系统调用

1.4 main 函数

int main(int argc, char **argv) {
 /* 代码 */
}

argc 形参表示传入参数的个数,包括应用程序自身路径和程序名

可能很难理解,举个例子

./hello 112233

那么此时参数个数为 2
argv[0]等于"./hello"
argv[1]等于"112233"

第二章 文件 I/O 基础

文件 I/O(Input、Outout)输入/输出操作

1.文件描述符

int 类型
一个进程最多可以打开 1024 个文件,可以改
文件描述符是从 0 开始分配的
如果文件被关闭后,它对应的文件描述符将会被释放

一般是从3开始的。系统默认开始就分配系统标准输入(0)、标准输出(1)以及标准错误(2)。后面的可能是vscode开始文件

2. oepn

在 Linux 系统中要操作一个文件,需要先打开该文件,得到文件描述符,然后再对文件进行相应的读写操作(或其他操作),最后在关闭该文件

write read close lseek

当打开文件时,会将读写偏移量设置为指向文件开始位置处,以后每次调用 read()、write()将自动对其进行调整,以指向已读或已写数据后的下一字节

注意!!!
⚫ 一个进程内多次 open 打开同一个文件,那么会得到多个不同的文件描述符 fd
⚫ 一个进程内多次 open 打开同一个文件,在内存中并不会存在多份动态文件。
⚫ 一个进程内多次 open 打开同一个文件,不同文件描述符所对应的读写位置偏移量是相互独立的。
在这里插入图片描述

复制文件描述符在

Linux 系统下,可以使用 dup 或 dup2 这两个系统调用对文件描述符进行复制
复制得到的文件描述符与旧的文件描述符都指向了同一个文件表

dup 系统调用分配的文件描述符是由系统分配的,遵循文件描述符分配原则,并不能自己指定一个文件描述符,这是 dup 系统调用的一个缺陷;而 dup2 系统调用修复了这个缺陷,可以手动指定文件描述符

文件共享

文件共享指的是同一个文件(譬如磁盘上的同一个文件,对应同一个 inode)被多个独立的读写体同时进行 IO 操作
读写体理解成文件描述符

常见的三种文件共享的实现方式
(1)同一个进程中多次调用 open 函数打开同一个文件
在这里插入图片描述
(2)不同进程中分别使用 open 函数打开同一个文件
在这里插入图片描述
(3)同一个进程中通过 dup(dup2)函数对文件描述符进行复制
在这里插入图片描述

3.1 Linux 系统如何管理文件

静态文件与 inode

文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、U 盘等外部存储设备。静态文件

硬盘的最小存储单位叫做“扇区”(Sector),每个扇区储存 512 字节(相当于 0.5KB)
操作系统读取硬盘的时候,一次性连续读取多个扇区
这种由多个扇区组成的==“块”,是文件存取的最小单位==。“块”的大小,最常见的是 4KB,即连续八个 sector 组成一个 block。

调用 open 函数是如何找到对应文件的数据存储“块”的呢
我们的磁盘在进行分区、格式化的时候会将其分为两个区域,
一个是数据区,用于存储文件中的数据;
另一个是 inode 区,用于存放 inode table(inode 表)
每一个文件都有唯一的一个 inode,每一个 inode 都有一个与之相对应的数字编号,通过这个数字编号就可以找到 inode table 中所对应的 inode。
在这里插入图片描述

U盘快速格式化只是删除了 inode table 表,真正存储文件数据的区域并没有动

文件打开时的状态

调用 open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区)
并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存(也把内存中的这份文件数据叫做动态文件、内核缓冲区)
打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件。

我们再来说一下,为什么要这样设计?

  • 磁盘、硬盘、U 盘等存储设备基本都是 Flash
    块设备,因为块设备硬件本身有一块一块为单位读写限制等特征。一个字节的改动也需要将该字节所在的 block
    全部读取出来进行修改,修改完成之后再写入块设备中,所以导致对块设备的读写操作非常不灵活
    而内存可以按字节为单位来操作,而且可以随机操作任意地址数据,非常地很灵活

在 Linux 系统中,内核会为每个进程设立进程控制块(PCB),用于记录进程的状态信息、运行特征等
PCB 数据结构体中有一个指针指向了文件描述符表(File descriptors),文件描述符表中的每一个元素索引到对应的文件表(File table),文件表也是一个数据结构体,其中记录了很多文件相关的信息,譬如文件状态标志、引用计数、当前文件的读写偏移量以及 i-node 指针(指向该文件对应的 inode)等,进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表
在这里插入图片描述

返回错误处理与 errno

Linux 系统下对常见的错误做了一个编号,每一个编号都代表着每一种不同的错误类型,
当函数执行发生错误的时候,操作系统会将这个错误所对应的编号赋值给 errno 变量,
每一个进程(程序)都维护了自己的 errno 变量,它是程序中的全局变量,该变量用于存储就近发生的函数执行错误编号,也就意味着下一次的错误码会覆盖上一次的错误码。

strerror 函数
perror 函数
查看错误信息,一般用的最多的还是这个函数,
调用此函数不需要传入 errno,函数内部会自己去获取 errno 变量的值
除此之外还可以在输出的错误提示字符串之前加入自己的打印信息
例子:perror(“open error”);

退出exit、_exit、_Exit

进程(程序)退出可以分为正常退出和异常退出
异常往往更多的是一种不可预料的系统异常,可能是执行了某个函数时发生的、也有可能是收到了某种信号等

进程正常退出除了可以使用 return 之外,还可以使用 exit()、_exit()以及_Exit()
1 main 函数中使用 return 后返回,return 执行后把控制权交给调用函数,结束该进程。
2 调用==_exit()或_Exit()函数会清除其使用的内存空间,并销毁其在内核中的各种数据结构,关闭进程的所有文件描述符,并结束进程、将控制权交给操作系统。==

3 exit()是一个标准 C 库函数,而_exit()和_Exit()是系统调用。
执行 exit()会执行一些清理工作,最后调用_exit()函数。推荐大家使用 exit()

空洞文件

lseek()系统调用还允许文件偏移量超出文件长度
文件的大小是 4K(也就是 4096 个字节),如果通过 lseek 系统调用将该文件的读写偏移量移动到偏移文件头部 6000 个字节处
意味着 4096~6000 字节之间出现了一个空洞,里面应该全是空字符‘\0’???
文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才分配

应用场景
⚫ 在使用迅雷下载文件时,还未下载完成,就发现该文件已经占据了全部文件大小的空间,这也是空洞文件;下载时如果没有空洞文件,多线程下载时文件就只能从一个地方写入,这就不能发挥多线程的作用了;如果有了空洞文件,可以从不同的地址同时写入,就达到了多线程的优势;
⚫ 在创建虚拟机时,你给虚拟机分配了 100G 的磁盘空间,但其实系统安装完成之后,开始也不过只用了 3、4G 的磁盘空间,如果一开始就把 100G 分配出去,资源是很大的浪费。

原子操作与竞争冒险

Linux 是一个多任务、多进程操作系统
多个不同的进程就有可能对同一个文件进行 IO 操作,此时该文件便是它们的共享资源,可能会导致的竞争冒险

竞争冒险

假设有两个独立的进程 A 和进程 B 都对同一个文件进行追加写操作(也就是在文件末尾写入数据),容易出现由于进程 A 的时间片耗尽,然后内核切换到了进程 B,从而覆盖数据。其操作之后的所得到的结果往往是不可预期的

原子操作

原子操作要么一步也不执行,一旦执行,必须要执行完所有步骤,不可能只执行所有步骤中的一个子集。

(1)O_APPEND 实现原子操作
(2)pread()和 pwrite() 调用 pread 函数或 pwrite 函数可传入一个位置偏移量 offset 参数
(3) O_CREAT| O_EXCL 创建一个文件 判断文件是否存在、存在返回错误

fcntl 和 ioctl

fcntl()函数可以对一个已经打开的文件描述符执行一系列控制操作
ioctl()可以认为是一个文件 IO 操作的杂物箱,可以处理的事情非常杂、不统一,一般用于操作特殊文件或硬件外设,此函数将会在进阶篇中使用到,譬如可以通过 ioctl 获取 LCD 相关信息等

截断文件

使用系统调用 truncate()或 ftruncate()可将普通文件截断为指定字节长度

第四章 标准 I/O 库

标准 I/O 虽然是对文 件 I/O 进行了封装,还会处理很多细节,譬如分配 stdio 缓冲区、以优化的块长度执行 I/O 等

FILE 指针

FILE 指针的作用相当于文件描述符

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

用户通过标准输入设备与系统进行交互,进程将从标准输入(stdin)文件中得到输入数据,将正常输出数据(譬如程序中 printf 打印输出的字符串)输出到标准输出(stdout)文件,而将错误信息(譬如函数调用报错打印的信息)输出到标准错误(stderr)文件。

标准输出文件和标准错误文件都对应终端的屏幕,而标准输入文件则对应于键盘。
每个进程启动之后都会默认打开标准输入、标准输出以及标准错误,得到三个文件描述符

#include<unistd.h>

/* Standard file descriptors. */
#define STDIN_FILENO 0 /* Standard input. */
#define STDOUT_FILENO1 /* Standard output. */
#define STDERR_FILENO2 /* Standard error output. */

打开文件 fopen()
fread()和 fwrite() 返回值是所写和读的大小

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

fseek 定位

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

库函数 ftell()可用于获取文件当前的读写位置偏移量
如果返回值小于参数 nmemb 所指定的值,表示发生了错误或者已经到了文件末尾(文件结束 end-of-file),但 fread()无法具体确定是哪一种情况

库函数 feof()用于测试参数 stream 所指文件的 end-of-file 标志,如果 end-of-file 标志被设置了,则调用feof()函数将返回一个非零值,如果 end-of-file 标志没有被设置,则返回 0。

库函数 ferror()用于测试参数 stream 所指文件的错误标志,如果错误标志被设置了,则调用 ferror()函数将返回一个非零值,如果错误标志没有被设置,则返回 0。

库函数 clearerr()用于清除 end-of-file 标志和错误标志,当调用 feof()或 ferror()校验这些标志后,通常需要清除这些标志,避免下次校验时使用到的是上一次设置的值,此时可以手动调用 clearerr()函数清除标志。

格式化 I/O

C 库函数提供了 5 个格式化输出函数,包括:printf()、fprintf()、dprintf()、sprintf()、snprintf()

#include <stdio.h>
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int dprintf(int fd, const char *format, ...);
int sprintf(char *buf, const char *format, ...);
int snprintf(char *buf, size_t size, const char *format, ...);

sprintf()函数可能会发生缓冲区溢出的问题,存在安全隐患
snprintf()如果写入到缓冲区的字节数大于参数 size 指定的大小,超出的部分将会被丢弃!如果缓冲区空间足够大,snprintf()函数就会返回写入到缓冲区的字符数

格式控制字符串 format
用于控制对应的参数如何进行转换
printf(“转换说明 1 转换说明 2 转换说明 3”, arg1, arg2, arg3);

格式化输入

C 库函数提供了 3 个格式化输入函数,包括:scanf()、fscanf()、sscanf()

#include <stdio.h>
int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);		//类似fprintf
int sscanf(const char *str, const char *format, ...);	//类似sprintf

I/O 缓冲

出于速度和效率的考虑,系统 I/O 调用(即文件 I/O,open、read、write 等)和==标准 C 语言库 I/O 函数(即标准 I/O 函数)==在操作磁盘文件时会对数据进行缓冲

1.文件 I/O 的内核缓冲

read()和 write()系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据。
在后面的某个时刻,内核会将其缓冲区中的数据写入(刷新)到磁盘设备中

刷新文件 I/O 的内核缓冲区

强制将文件 I/O 内核缓冲区中缓存的数据写入(刷新)到磁盘设备中
系统调用 fsync()将参数 fd 所指文件的内容数据和元数据写入磁盘,只有在对磁盘设备的写入操作完成之后,fsync()函数才会返回

#include <unistd.h>
int fsync(int fd);

系统调用 fdatasync()与 fsync()类似。刷新所有文件 I/O 内核缓冲区

第五章 文件属性与目录

Linux 系统中的文件类型

普通文件
最常见的,譬如文本文件、二进制文件,我们编写的源代码文件这些都是普通文件
普通文件可以分为两大类:文本文件和二进制文件。
⚫ 文本文件:文件中的内容是由文本构成的,所谓文本指的是 ASCII 码字符。譬如常见的.c、.h、.sh、.txt 等
⚫ 二进制文件:二进制文件中存储的本质上也是数字,真正的数字。譬如 Linux 系统下的可执行文件、C 代码编译之后得到的.o 文件、.bin 文件等都是二进制文件。

在 Linux 系统下,可以通过 stat 命令或者 ls 命令来查看文件类型
ls中
⚫ ’ - ':普通文件
⚫ ’ d ':目录文件
⚫ ’ c ':字符设备文件
⚫ ’ b ':块设备文件
⚫ ’ l ':符号链接文件
⚫ ’ s ':套接字文件
⚫ ’ p ':管道文件
关于普通文件就给大家介绍这么多。

目录文件
录(directory)就是文件夹,文件夹在 Linux 系统中也是一种文件,是一种特殊文件
文件夹中记录了该文件夹本省的路径以及该文件夹下所存放的文件

字符设备文件和块设备文件
Linux 系统中,可将硬件设备分为字符设备和块设备
硬件设备会对应到一个设备文件,应用程序通过对设备文件的读写来操控、使用硬件设备
但是设备文件并不存在于磁盘中,而是由文件系统虚拟出来的,一般是由内存来维护
字符设备文件一般存放在 Linux 系统/dev/目录下,所以/dev 也称为虚拟文件系统 devfs

**符号链接文件(link)**类似于 Windows 系统中的快捷方式文件,是一种特殊文件,它的内容指向的是另一个文件路径

stat 函数
Linux 下可以使用 stat 命令查看文件的属性

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
int stat(const char *pathname, struct stat *buf);

struct stat 是内核定义的一个结构体。这个结构体中的所有元素加起来构成了文件的属性信息

struct stat
{
dev_t st_dev;
ino_t st_ino;
mode_t st_mode;
nlink_t st_nlink;
uid_t st_uid;
gid_t st_gid;
dev_t st_rdev;
off_t st_size;
blksize_t st_blksize;
blkcnt_t st_blocks;
struct timespec st_atim;
struct timespec st_mtim;	/*文件内容最后被修改的时间 */
struct timespec st_ctim;	/* 文件状态最后被改变的时间 */
};

判断文件所有者对该文件是否具有可执行权限

if (st.st_mode & S_IXUSR) {
//有权限
} else {
//无权限
}

判断是不是普通文件

// S_IFMT 宏是文件类型字段位掩码: S_IFMT 0170000
if ((st.st_mode & S_IFMT) == S_IFREG) {
/* 是 */
}
/* 判断是不是链接文件 */
if ((st.st_mode & S_IFMT) == S_IFLNK) {
/* 是 */
}

fstat 和 lstat 函数
stat 是从文件名出发得到文件属性信息,不需要先打开文件
使用 fstat 函数之前需要先打开文件得到文件描述符
对于符号链接文件,stat、fstat 查阅的是符号链接文件所指向的文件对应的文件属性信息,而 lstat 查阅的是符号链接文件本身的属性信息。

在这里插入图片描述

  • 实际用户 ID 和实际组 ID 标识我们究竟是谁,也就是执行该进程的用户是谁、以及该用户对应的所属组;实际用户 ID 和实际组
    ID 确定了进程所属的用户和组。 进程的有效用户 ID、有效组 ID 以及附属组 ID 用于文件访问权限检查

首先对于有效用户 ID 和有效组 ID 来说,这是进程所持有的概念,对于文件来说,并无此属性!
通常,绝大部分情况下,进程的有效用户等于实际用户(有效用户 ID 等于实际用户 ID),有效组等于实际组(有效组 ID 等于实际组 ID)。

chown fchown 和 lchown 函数
只有超级用户进程能更改文件的用户 ID;

文件访问权限

文件的权限可以分为两个大类,分别是普通权限和特殊权限(也可称为附加权限)
在这里插入图片描述
r 表示具有读权限;
w 表示具有写权限;
x 表示具有执行权限;
-表示无此权限。

特殊权限
在这里插入图片描述S 字段三个 bit 位中,从高位到低位依次表示文件的 set-user-ID 位权限、set-group-ID 位权限以及 sticky 位权限

S_ISUID 04000 set-user-ID bit
S_ISGID 02000 set-group-ID bit (see below)
S_ISVTX 01000 sticky bit (see below)

以上数字使用的是八进制方式表示。对应的 bit 位数字为 1,则表示设置了该权限

if (st.st_mode & S_ISUID) {
//设置了 set-user-ID 位权限
} else {
//没有设置 set-user-ID 位权限
}

当进程对文件进行操作的时候、将进行权限检查,如果文件的 set-user-ID 位权限被设置,内核会将进程的有效 ID 设置为该文件的用户 ID(文件所有者 ID)。意味着该进程直接获取了文件所有者
的权限、以文件所有者的身份操作该文件。

目录权限
创建文件、删除文件

  • 目录的读权限:可列出(譬如:通过 ls 命令)目录之下的内容(即目录下有哪些文件)。 目录的写权限:可以在目录下创建文件、删除文件。
    目录的执行权限:可访问目录下的文件,譬如对目录下的文件进行读、写、执行等操作。

检查文件权限 access

#include <unistd.h>
int access(const char *pathname, int mode);

mode:该参数可以取以下值:
⚫ F_OK:检查文件是否存在
⚫ R_OK:检查是否拥有读权限
⚫ W_OK:检查是否拥有写权限
⚫ X_OK:检查是否拥有执行权限

chmod fchomd

umask 函数
umask 不能对特殊权限位进行屏蔽
umask 权限掩码是进程的一种属性,用于指明该进程新建文件或目录时,应屏蔽哪些权限位。进程的umask 通常继承至其父进程

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

返回值:返回设置之前的 umask 值,也就是旧的 umask。

文件的时间属性
在这里插入图片描述
time()查看当前系统时间
utime()和 utimes()函数来修改文件的时间属性
futimens()和 utimensat()功能与 utime()和 utimes()函数功能一样,用于显式修改文件时间戳
⚫ 可按纳秒级精度设置时间戳。相对于提供微秒级精度的 utimes(),这是重大改进!
⚫ 可单独设置某一时间戳。譬如,只设置访问时间、而修改时间保持不变

符号链接(软链接)与硬链接

软链接文件也就是前面给大家的 Linux 系统下的七种文件类型之一
使用 ln 命令创建的两个硬链接文件与源文件都拥有相同的 inode 号
既然inode 相同,也就意味着它们指向了物理硬盘的同一个区块,仅仅只是文件名字不同而已,创建出来的硬链接文件与源文件对文件系统来说是完全平等的关系
当为文件每创建一个硬链接,inode 节点上的链接数就会加一,每删除一个硬链接,inode 节点上的链接数就会减一,直到为 0,inode 节点和对应的数据块才会被文件系统所回收

软链接文件与源文件有着不同的 inode 号。软链接文件的数据块中存储的是源文件的路径名,链接文件可以通过这个路径找到被链接的源文件,它们之间类似于一种“主从”关系

对于硬链接来说,存在一些限制情况
⚫ 不能对目录创建硬链接(超级用户可以创建,但必须在底层文件系统支持的情况下)
⚫ 硬链接通常要求链接文件和源文件位于同一文件系统中。
⚫ 不可以对不存在的文件创建软链接。

link()系统调用用于创建硬链接文件

ret = link("./test_file", "./hard");

创建软链接 symlink()
读取软链接文件
调用 open 打开一个链接文件本身是不会成功的,因为打开的并不是链接文件本身、而是其指向的文件

ret = readlink("./soft", buf, sizeof(buf));

目录

目录作为一种特殊文件,并不适合使用前面介绍的文件 I/O 方式进行读写等操作
在 Linux 系统下,会有一些专门的系统调用或 C 库函数用于对文件夹进行操作

目录存储形式
目录在文件系统中的存储方式与常规文件类似
在这里插入图片描述
1.mkdir
2.rmdir
3.opendir
4.readdir

#include <dirent.h>
struct dirent *readdir(DIR *dirp);
struct dirent {
ino_t d_ino;
/* inode 编号 */
off_t d_off;
/* not an offset; see NOTES */
unsigned short d_reclen;
/* length of this record */
unsigned char d_type;
/* type of file; not supported by all filesystem types */
char d_name[256]; /* 文件名 */
};

对于 struct dirent 结构体,我们只需要关注 d_ino 和 d_name 两个字段即可,分别记录了文件的 inode 编号和文件名
每调用一次 readdir(),就会从 drip 所指向的目录流中读取下一条目录项(目录条目)

5.rewindir
可将目录流重置为目录起点,以便对 readdir()的下一次调用将从目录列表中的第一个文件开始6.
6.closedir()
关闭处于打开状态的目录,同时释放它所使用的资源

进程的当前工作目录
Linux 下的每一个进程都有自己的当前工作目录(current working directory),当前工作目录是该进程解析、搜索相对路径名的起点(不是以" / "斜杆开头的绝对路径)

getcwd 函数来获取进程的当前工作目录

#include <unistd.h>
char *getcwd(char *buf, size_t size);

改变当前工作目录
系统调用 chdir()和 fchdir()可以用于更改进程的当前工作目录

#include <unistd.h>
int chdir(const char *path);
int fchdir(int fd);

删除普通文件
系统调用 unlink()或使用 C 库函数 remove()

unlink()系统调用用于移除/删除一个硬链接
unlink()系统调用并不会对软链接进行解引用操作,若 pathname 指定的文件为软链接文件,则删除软链接文件本身,而非软链接所指定的文件。

remove()是一个 C 库函数,用于移除一个文件或空目录
remove()不对软链接进行解引用操作

文件重命名rename

ret = rename("./test_file", "./new_file");

第六章 字符串处理

字符串输入/输出

put 输出字符串并自行换行。把字符串输出到标准输出设备,将’ \0 ‘转换为换行符’ \n ’
putchar() 函数可以把参数 c 指定的字符==(一个无符号字符)==输出到标准输出设备

fputc 函数 与 putchar()区别在于,putchar()只能输出到标准输出设备,而 fputc()可把字符输出到指定的文件中,既可以是标准输出、标准错误设备,也可以是一个普通文件。
fputs()与 puts()类似,也用于输出一条字符串

gets()函数用于从标准输入设备(譬如键盘)中获取用户输入的字符串
用户从键盘输入的字符串数据首先会存放在一个输入缓冲区中,gets()函数会从输入缓冲区中读取字符串存储到字符指针变量 s 所指向的内存空间,当从输入缓冲区中读走字符后,相应的字符便不存在于缓冲区

⚫ gets()函数不仅比 scanf 简洁,而且,就算输入的字符串中有空格也可以,因为 gets()函数允许输入的字符串带有空格、制表符,输入的空格和制表符也是字符串的一部分,仅以回车换行符作为字符串的分割符。而对于 scanf以%s 格式输入的时候,空格、换行符、TAB 制表符等都是作为字符串分割符存在

⚫ gets()会将回车换行符从输入缓冲区中取出来,然后将其丢弃,缓冲区中将不会遗留下回车换行符。scanf相反,缓冲区中依然还存在用户输入的分隔符

#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char s1[100] = {0};
char s2[100] = {0};
scanf("%s", s1);
printf("s1: %s\n", s1);
scanf("%s", s2);
printf("s2: %s\n", s2);
exit(0);
}

在这里插入图片描述
getchar()只从标准输入设备文件输入缓冲区中读取一个字符,与 scanf 以%c 格式读取一样,空格、TAB 制表符、回车符都将是正常的字符。即使输入了多个字符,但 getchar()仅读取一个字符。

fgets()可以设置获取字符串的最大字符数。 使用 fgets()读取文件中输入的字符串,文件指针会随着读取的字节数向前移动。
fgetc

字符串长度
strlen() 返回字符串长度(以字节为单位),字符串结束字符’ \0 '不计算在内。

编译器在编译时就计算出了 sizeof 的结果,而 strlen 必须在运行时才能计算出来;

字符串拼接
C 语言函数库中提供了 strcat()函数或 strncat()函数用于将两个字符串连接(拼接)起来

char *strcat(char *dest, const char *src);
char *strncat(char *dest, const char *src, size_t n);

strcat()函数会把 src 所指向的字符串追加到 dest 所指向的字符串末尾,所以必须要保证== dest 有足够的存储空间==来容纳两个字符串,否则会导致溢出错误;dest 末尾的’ \0 '结束字符会被覆盖

strncat()与 strcat()的区别在于,strncat 可以指定源字符串追加到目标字符串的字符数量

字符串拷贝
C 语言函数库中提供了 strcpy()函数和 strncpy()函数用于实现字符串拷贝
当 n 小于或等于 src 字符串长度(不包括结束字符的长度)时,则复制过去的字符串中没有包含结束字符’ \0 ‘;
当 n 大于 src 字符串长度时,则会将 src 字符串的结束字符’ \0 '也一并拷贝过去
在这里插入图片描述
除了 strcpy()和 strncpy()之外,其实还可以使用 memcpy()、 memmove()以及 bcopy()这些库函数实现拷贝操作,字符串拷贝本质上也只是内存数据的拷贝

memset
C 语言函数库提供了用于字符串比较的函数 strcmp()和 strncmp()
返回值:
⚫ 如果返回值小于 0,则表示 str1 小于 str2
⚫ 如果返回值大于 0,则表示 str1 大于 str2
⚫ 如果返回值等于 0,则表示字符串 str1 等于字符串 str2
主要是通过比较字符串中的字符对应的 ASCII 码值,直到出现了不同的字符

字符串查找
C 语言函数库中也提供了一些用于字符串查找的函数,包括 strchr()、strrchr()、strstr()、strpbrk()、index()以及 rindex()等

字符串与数字互转
一个字符串转为整形数据,主要包括 atoi()、atol()、atoll()以及
strtol()、strtoll()、strtoul()、strtoull()等,它们之间的区别主要包括以下两个方面:
⚫ 数据类型(int、long int、unsigned long 等)。
⚫ 不同进制方式表示的数字字符串(八进制、十六进制、十进制)。

**atoi()、atol()、atoll()**三个函数可用于将字符串分别转换为 int、long int 以及 long long 类型的数据(十进制)
**strtol()、strtoll()**可以实现将多种不同进制数(譬如二进制表示的数字字符串、八进制表示的数字字符串、十六进制表示的数数字符串)表示的字符串转换为整形数据

#include <stdlib.h>
long int strtol(const char *nptr, char **endptr, int base);
long long int strtoll(const char *nptr, char **endptr, int base);
printf("strtol: %ld\n", strtol("0x500", NULL, 16));

base:数字基数,参数 base 必须介于 2 和 36(包含)之间,或者是特殊值 0。参数 base 决定了字符串转换为整数时合法字符的取值范围,譬如,当 base=2 时,合法字符为’ 0 ‘、’ 1 ‘(表示是一个二进制表示的数字字符串);当 base=8 时,合法字符为’ 0 ‘、’ 1 ‘、’ 2 ‘、’ 3 ‘……’ 7 ‘(表示是一个八进制表示的数字字符串);当 base=16 时,合法字符为’ 0 ’ 、’ 1 ‘、’ 2 ‘、’ 3 ‘……’ 9 ‘、’ a ‘……’ f '(表示是一个十六进制表示的数字字符串);

在 base=0 的情况下,如果字符串包含一个了“0x”前缀,表示该数字将以 16 为基数;
如果包含的是“0”前缀,表示该数字将以 8 为基数。

当 base=16 时,字符串可以使用“0x”前缀。
在这里插入图片描述

strtoul、strtoull 函数
使用方法与 strtol()、strtoll()一样
strtoul()返回值类型是 unsignedlong int,strtoull()返回值类型是 unsigned long long int

字符串转浮点型数据
C 函数库中用于字符串转浮点型数据的函数有 atof()、strtod()、strtof()、strtold()。
strtof()、strtod()以及 strtold()三个库函数可分别将字符串转换为 float 类型数据、double 类型数据、long double 类型数据

printf("atof: %lf\n", atof("0.123"));
printf("strtof: %f\n", strtof("0.123", NULL));

数字转字符串
sprintf()或 snprintf()

给应用程序传参

一个能够接受外部传参的应用程序往往使用上会比较灵活,根据参入不同的参数实现不同的功能

int main(int argc, char *argv[])
{
/* 代码 */
}

传递进来的参数以字符串的形式存在,字符串的起始地址存储在 argv 数组中,参数 argc 表示传递进来的参数个数,包括应用程序自身路径名
多个不同的参数之间使用空格分隔开来,如果参数本身带有空格、则可以使用双引号" "或者单引号’ '的形式来表示

正则表达式
通常会有这样的需要:给定一个字符串,检查该字符串是否符合某种条件或规则、或者从给定的字符串中找出符合某种条件或规则的子字符串,将匹配到的字符串提取出来。

譬如给定一个字符串,在程序当中判断该字符串是否是一个 IP 地址,对于实现这个功能,大家可能首先想到的是,使用万能的 for 循环,当然,笔者首先肯定的是,使用 for 循环自然是可以解决这个问题,但是在程序代码处理上会比较麻烦

正则表达式,又称为规则表达式(英语: Regular Expression)
检索、替换那些符合某个模式(规则)的字符串
描述了一种字符串的匹配模式(pattern)
可以用来检查一个给定的字符串中是否含有某种子字符串、将匹配的字符串替换或者从某个字符串中取出符合某个条件的子字符串。

通配符
?通配符匹配 0 个或 1 个字符,而*通配符匹配 0 个或多个字符
譬如"data?.txt"这样的匹配模式可以将下列文件查找出来:
data.dat
data1.dat
data2.dat
datax.dat
dataN.dat

尽管使用通配符的方法很有用,但它还是很有限,正则表达式则更加强大、更加灵活。

正则表达式其实也是一个字符串
该字符串由普通字符(譬如,数字 0~9、大小写字母以及其它字符)和特殊字符(称为“元字符”)所组成
由这些字符组成一个“规则字符串”。这个“规则字符串”用来表达对给定字符串的一种查找、匹配逻辑。

C 语言中使用正则表达式
C 语言中使用正则表达式
1)编译正则表达式 regcomp()
2)匹配正则表达式 regexec()
3)释放正则表达式 regfree()
匹配 URL 的正则表达式:

^((ht|f)tps?)://[-A-Za-z0-9_]+(\.[-A-Za-z0-9_]+)+([-A-Za-z0-9_.,@?^=%&:/~+#]*[-A-Za-z0-9_@?^=%&/~+#])?$

1.int regcomp (regex_t *compiled, const char *pattern, int cflags)
regcomp()函数把指定的正则表达式pattern编译成一种特定的数据格式(参数regex_t *compiled),这样可以使匹配更有效。
③cflags 有如下4个值或者是它们或运算(|)后的值:
REG_EXTENDED 以功能更加强大的扩展正则表达式的方式进行匹配。
REG_ICASE 匹配字母时忽略大小写。
REG_NOSUB 不用存储匹配后的结果。
REG_NEWLINE 识别换行符,这样’$‘就可以从行尾开始匹配,’^'就可以从行的开头开始匹配。

2.int regexec (regex_t *compiled, char *string, size_t nmatch, regmatch_t matchptr [], int eflags)
当我们编译好正则表达式后,就可以用regexec 匹配我们的目标文本串了,如果在编译正则表达式的时候没有指定cflags的参数为REG_NEWLINE,则默认情况下是忽略换行符的,也就是把整个文本串当作一个字符串处理

3.void regfree (regex_t *compiled)
当我们使用完编译好的正则表达式后,或者要重新编译其他正则表达式的时候,我们可以用这个函数清空compiled指向的regex_t结构体的内容,请记住,如果是重新编译的话,一定要先清空regex_t结构体。

4.size_t regerror (int errcode, regex_t *compiled, char *buffer, size_t length)
当执行regcomp 或者regexec 产生错误的时候,就可以调用这个函数而返回一个包含错误信息的字符串。

#include <stdio.h>
#include <sys/types.h>
#include <regex.h>
 
int main(int argc,char** argv)
{
	int status ,i;
	int cflags = REG_EXTENDED;
	regmatch_t pmatch[1];
	const size_t nmatch = 1;	//最多匹配出的结果
	regex_t reg;
	const char * pattern = "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*.\\w+([-.]\\w+)*$";
	char * buf = "chenjiayi@126.com";
	char errbuf[64];


/* 编译正则表达式 */
	if(regcomp(&reg,pattern,cflags)){
		regerror(ret, &reg, errbuf, sizeof(errbuf));
		fprintf(stderr, "regcomp error: %s\n", errbuf);
		exit(0);
	}
	
	status = regexec(&reg,buf,nmatch,0);//执行正则表达式和缓存的比较
	if(status == REG_NOMATCH)
		printf("No match\n");
	else if (0 == status)
	{
		printf("比较成功:");
		for(i = pmatch[0].rm_so;i<pmatch[0].rm_eo;++i)putchar(buf[i]);
		printf("\n");
	}
	regfree(&reg);
	return 0;
}

基础语法 “^([]{})([]{})([]{})$”
算了太难了,用到再学

第七章 系统信息与系统资源

在应用程序当中,有时往往需要去获取到一些系统相关的信息,譬如时间、日期、以及其它一些系统相关信息
除此之外,还会向大家介绍 Linux 系统下的/proc 虚拟文件系统

uname()用于获取有关当前操作系统内核的名称和信息
sysinfo 系统调用可用于获取一些系统统计信息
gethostname 函数可用于单独获取 Linux 系统主机名
sysconf()函数可在运行时获取系统的一些配置信息,譬如页大小(page size)、主机名的最大长度、进程可以打开的最大文件数、每个用户 ID 的最大并发进程数等

时间、日期

GMT 时间就是英国格林威治当地时间,也就是零时区(中时区)所在时间,与我国的标准时间北京时间(东八区)相差 早8 个小时
GMT 与 UTC 这两者几乎是同一概念,它们都是指格林威治标准时间,也就是国际标准时间,只不过UTC 时间比 GMT 时间更加精准,所以在我们的编程当中不用刻意去区分它们之间的区别。
CST 在这里其实指的是 China Standard Time(中国标准时间)的缩写,表示当前查看到的时间是中国标准时间,也就是我国所使用的标准时间–北京时间
在 Ubuntu 系统下,时区信息通常以标准格式保存在一些文件当中,这些文件通常位于/usr/share/zoneinfo目录下,该目录下的每一个文件(包括子目录下的文件)都包含了一个特定国家或地区内时区制度的相关信息

系统的本地时间由时区配置文件/etc/localtime 定义,通常链接到/usr/share/zoneinfo 目录下的某一个文件
如果我们要修改 Ubuntu 系统本地时间的时区信息,可以直接将/etc/localtime 链接到/usr/share/zoneinfo目录下的任意一个时区配置文件,譬如 EST(美国东部标准时间),首先进入到/etc 目录下,执行下面的命令:

sudo rm -rf localtime
#删除原有链接文件
sudo ln -s /usr/share/zoneinfo/EST localtime
#重新建立链接文件

Linux 系统中的时间
点时间和段时间:时间点,时间段
实时时钟 RTC:操作系统中一般会有两个时钟,一个系统时钟(system clock),一个实时时钟(Real time clock)
系统时钟由系统启动之后由内核来维护,譬如使用 date 命令查看到的就是系统时钟,所以在系统关机情况下是不存在的;而实时时钟一般由 RTC 时钟芯片提供,RTC 芯片有相应的电池为其供电,以保证系统在关机情况下 RTC 能够继续工作、继续计时。

Linux 系统在开机启动之后首先会读取 RTC 硬件获取实时时钟作为系统时钟的初始值,之后内核便开始维护自己的系统时钟。
RTC 硬件只有在系统开机启动时会读取一次。系统关机时,内核会将系统时钟写入到 RTC 硬件、已进行同步操作。

jiffies 的引入
jiffies 是内核中定义的一个全局变量,内核使用 jiffies 来记录系统从启动以来的系统节拍数
Linux 内核在编译配置时定义了一个节拍时间,使用节拍率(一秒钟多少个节拍数)来表示
配置的节拍率越高,每一个系统节拍的时间就越短,也就意味着 jiffies 记录的时间精度越高,
当然,高节拍率会导致系统中断的产生更加频繁,频繁的中断会加剧系统的负担,一般默认情况下都是采用 100Hz 作为系统节拍率。

获得时间段
通过 time()或 gettimeofday()函数可以获取到当前时间点相对于 1970-01-01 00:00:00 +0000 (UTC)这个时间点所经过时间(日历时间),所以获取得到的是一个时间段的长度
time 函数获取得到的是一个时间段,也就是从 1970-01-01 00:00:00 +0000 (UTC)到现在这段时间所经过的秒数
gettimeofday()函数微秒级

时间转换函数

#include <time.h>
char *ctime(const time_t *timep);
char *ctime_r(const time_t *timep, char *buf);

打印出来的时间为"Mon Feb 22 17:10:46 2021"

#include <time.h>
struct tm *localtime(const time_t *timep);
struct tm *localtime_r(const time_t *timep, struct tm *result);

从 struct tm 结构体内容可知,该结构体中包含了年月日时分秒星期等信息
使用 localtime/localtime_r()便可以将 time_t 时间总秒数分解成了各个独立的时间信息

#include <time.h>
struct tm *gmtime(const time_t *timep);
struct tm *gmtime_r(const time_t *timep, struct tm *result);

gmtime()函数所得到的是 UTC 国际标准时间

#include <time.h>
time_t mktime(struct tm *tm);

mktime()函数与 localtime()函数相反, mktime()可以将使用 struct tm 结构体表示的分解时间转换为 time_t时间

asctime()函数与 ctime()函数的作用一样.ctime()是将 time_t 时间转换为固定格式字符串、而 asctime()则是将 struct tm 表示的分解时间转换为固定格式的字符串

strftime 函数功能上比 asctime()和 ctime()更加强大,它可以根据自己的喜好自定义时间的显示格式

#include <sys/time.h>
int settimeofday(const struct timeval *tv, const struct timezone *tz);

使用 settimeofday()函数可以设置时间,也就是设置系统的本地时间

总结

在这里插入图片描述

进程时间

进程时间指的是进程从创建后(也就是程序运行后)到目前为止这段时间内使用 CPU 资源的时间总数

内核把 CPU 时间(进程时间)分为以下两个部分:
⚫ 用户 CPU 时间:进程在用户空间(用户态)下运行所花费的 CPU 时间。有时也成为虚拟时间(virtual time)。
⚫ 系统 CPU 时间:进程在内核空间(内核态)下运行所花费的 CPU 时间。这是内核执行系统调用或代表进程执行的其它任务(譬如,服务页错误)所花费的时间。

Tips:进程时间不等于程序的整个生命周期所消耗的时间,如果进程一直处于休眠状态(进程被挂起、不会得到系统调度),那么它并不会使用 CPU 资源,所以休眠的这段时间并不计算在进程时间中。

#include <sys/times.h>
clock_t times(struct tms *buf);

struct tms {
clock_t tms_utime; /* user time, 进程的用户 CPU 时间, !!!tms_utime 个系统节拍数!!! */
clock_t tms_stime; /* system time, 进程的系统 CPU 时间, tms_stime 个系统节拍数 */
clock_t tms_cutime; /* user time of children, 已死掉子进程的 tms_utime + tms_cutime 时间总和 */
clock_t tms_cstime; /* system time of children, 已死掉子进程的 tms_stime + tms_cstime 时间总和 */
};

times()函数用于获取当前进程时间
注意tms里面的是系统节拍数!!!

#include <time.h>
clock_t clock(void);

库函数 clock()提供了一个更为简单的方式用于进程时间,它的返回值描述了进程使用的总的 CPU 时间
(也就是进程时间,包括用户 CPU 时间和系统 CPU 时间)
但并不能获取到单独的用户 CPU 时间和系统 CPU 时间

产生随机数

C 语言函数库中提供了很多函数用于产生伪随机数,其中最常用的是通过 rand()和 srand()产生随机数
调用 rand()可以得到[0, RAND_MAX]之间的伪随机数,多次调用 rand()便可以生成一组伪随机树序列,但是就是每一次运行程序所得到的随机数序列都是相同的。

如果没有调用 srand()设置随机数种子的情况下,rand()会将 1 作为随机数种子,如果随机数种子相同,那么每一次启动应用程序所得到的随机数序列就是一样的

void srand(unsigned int seed);
srand(time(NULL));

一般将当前时间作为随机数种子赋值给参数 seed。譬如 time(NULL)

休眠

有时需要将进程暂停或休眠一段时间,进入休眠状态之后,程序将暂停运行
一旦执行 sleep(),进程便主动交出 CPU 使用权,暂时退出系统调度队列
秒级休眠: sleep
微秒级休眠: usleep
高精度休眠: nanosleep

#include <time.h>
int nanosleep(const struct timespec *req, struct timespec *rem);

req:一个 struct timespec 结构体指针,指向一个 struct timespec 变量,用于设置休眠时间长度,可精确到纳秒级别。
rem:也是一个 struct timespec 结构体指针,指向一个 struct timespec 变量,也可设置 NULL。
返回值:在成功休眠达到请求的时间间隔后,nanosleep()返回 0;如果中途被信号中断或遇到错误,则返回-1,并将剩余时间记录在参数 rem 指向的 struct timespec 结构体变量中(参数 rem 不为 NULL 的情况下,如果为 NULL 表示不接收剩余时间),还会设置 errno 标识错误类型。

struct timespec 结构体,该结构体包含了两个成员变量,秒(tv_sec)和纳秒(tv_nsec)

申请堆内存

malloc 和 free

#include <stdlib.h>
void *malloc(size_t size);

返回值:返回值为 void *类型,如果申请分配内存成功,将返回一个指向该段内存的指针
void *并不是说没有返回值或者返回空指针,而是返回的指针类型未知
所以在调用 malloc()时通常需要进行强制类型转换,将 void *指针类型转换成我们希望的类型;
如果分配内存失败(譬如系统堆内存不足)将返回 NULL,
如果参数 size 为 0,返回值也是 NULL

malloc()在堆区分配一块指定大小的内存空间。它们的值是未知的,所以通常需要程序员对 malloc()分配的堆内存进行初始化操作。

调用 free()还是不调用 free()
Linux 系统中。基于内存的这一自动释放机制,很多应用程序通常会省略对 free()函数的调用。
大多数情况下,都是根据代码需求动态申请、释放的。如果持续占用,将会导致内存泄漏,也就是人们常说的“你的程序在吃内存”!

#include <stdlib.h>
void *calloc(size_t nmemb, size_t size);

calloc()函数用来动态地分配内存空间并初始化为 0
个人感觉用calloc方便

对齐内存在某些应用场合非常有必要,
常用于分配对其内存的库函数有:posix_memalign()、aligned_alloc()、memalign()、valloc()、pvalloc()

#include <stdlib.h>

int posix_memalign(void **memptr, size_t alignment, size_t size);
void *aligned_alloc(size_t alignment, size_t size);
void *valloc(size_t size);

#include <malloc.h>

void *memalign(size_t alignment, size_t size);
void *pvalloc(size_t size);

前面介绍的 malloc()、calloc()分配内存返回的地址其实也是对齐的,但是它俩的对齐都是固定的,并且对其的字节边界比较小
譬如在 32 位系统中,通常是以 8 字节为边界进行对其,在 64 位系统中是以 16 字节进行对齐。

什么是内存对齐?
尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度
现在考虑4字节存取粒度的处理器取int类型变量(32位系统),该处理器只能从地址为4的倍数的内存开始读取数据。
假如没有内存对齐机制,数据可以任意存放,现在一个int变量存放在从地址1开始的联系四个字节地址中,该处理器去取数据时,要先从0地址开始读取第一个4字节块,剔除不想要的字节(0地址),然后从地址4开始读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下的两块数据合并放入寄存器.这需要做很多工作.
这都是前面存了一字节的数据导致的!!!
在这里插入图片描述
现在有了内存对齐的,int类型数据只能存放在按照对齐规则的内存中,比如说0地址开始的内存。那么现在该处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,提高了效率。
在这里插入图片描述

内存对齐规则
每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。gcc中默认#pragma pack(4),可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。
有效对其值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位

了解了上面的概念后,我们现在可以来看看内存对齐需要遵循的规则:
(1) 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。(我也不理解,看图吧)

(3)== 结构体的总大小为 有效对齐值 的整数倍==,如有需要编译器会在最末一个成员之后加上填充字节。

//32位系统
#include<stdio.h>
struct
{
    int i;    
    char c1;  
    char c2;  
}x1;

struct{
    char c1;  
    int i;    
    char c2;  
}x2;

struct{
    char c1;  
    char c2; 
    int i;    
}x3;

int main()
{
    printf("%d\n",sizeof(x1));  // 输出8
    printf("%d\n",sizeof(x2));  // 输出12
    printf("%d\n",sizeof(x3));  // 输出8
    return 0;
}

在这里插入图片描述

回到这里来posix_memalign()函数

int posix_memalign(void **memptr, size_t alignment, size_t size);
注释:posix_memalign()函数用于在堆上分配 size 个字节大小的对齐内存空间,
将*memptr 指向分配的空间,分配的内存地址将是参数 alignment 的整数倍。
参数 alignment 表示对齐字节数,alignment 必须是 2 的幂次方(譬如 2^42^52^8 等),同时也要是 sizeof(void *)的整数倍,对于 32 位系统来说,sizeof(void *)等于4,如果是 64 位系统 sizeof(void *)等于 8/* 申请1024字节内存: 256 字节对齐 */
ret = posix_memalign((void **)&base, 256, 1024);

上面的东西很重要哦

memalign()与 aligned_alloc()参数是一样的,它们之间的区别在于:对于参数 size 必须是参数 alignment的整数倍这个限制条件,memalign()并没有这个限制条件。
Tips:memalign()函数已经过时了,并不提倡使用!

valloc()分配 size 个字节大小的内存空间,返回指向该内存空间的指针,内存空间的地址是页大小(pagesize)的倍数。
操作系统 页式存储 页与块之间的关系详解
对程序进行分页存储
对内存进行分块存储
都是2k大小

Tips:valloc()函数已经过时了,并不提倡使用!

proc 文件系统
proc 文件系统是一个虚拟文件系统,它以文件系统的方式为应用层访问系统内核数据提供了接口
用户和应用程序可以通过 proc 文件系统得到系统信息和进程相关信息,对 proc 文件系统的读写作为与内核进行通信的一种手段

proc 文件系统是动态创建的,文件本身并不存在于磁盘当中、只存在于内存当中,与 devfs 一样,都被称为虚拟文件系统

proc 文件系统是为了提供有关系统中进程相关的信息。内核中的很多信息也开始使用它来报告,或启用动态运行时配置
它会将内核运行时的一些关键数据信息以文件的方式呈现在 proc 文件系统下的一些特定文件中,这样相当于将一些不可见的内核中的数据结构以可视化的方式呈现给应用层。

proc 文件系统挂载在系统的/proc 目录下
对于内核开发者(譬如驱动开发工程师)来说,proc 文件系统给了开发者一种调试内核的方法:通过查看/proc/xxx 文件来获取到内核特定数据结构的值,在添加了新功能前后进行对比,就可以判断此功能所产生的影响是否合理。
在这里插入图片描述有很多以数字命名的文件夹,譬如 100038、2299、98560,这些数字对应的其实就是一个一个的进程 PID 号
PID号–每一个进程在内核中都会存在一个编号,通过此编号来区分不同的进程

/proc 目录下除了文件夹之外,还有很多的虚拟文件,譬如 buddyinfo、cgroups、cmdline、version 等等,
不同的文件记录了不同信息,关于这些文件记录的信息和意思如下:
⚫ cmdline:内核启动参数;
⚫ cpuinfo:CPU 相关信息;
⚫ iomem:IO 设备的内存使用情况;
⚫ interrupts:显示被占用的中断号和占用者相关的信息;
⚫ ioports:IO 端口的使用情况;
⚫ kcore:系统物理内存映像,不可读取;
⚫ loadavg:系统平均负载;
⚫ meminfo:物理内存和交换分区使用情况;
⚫ modules:加载的模块列表;
⚫ mounts:挂载的文件系统列表;
⚫ partitions:系统识别的分区表;
⚫ swaps:交换分区的利用情况;
⚫ version:内核版本信息;
⚫ uptime:系统运行时间;

proc 文件系统的使用
proc 文件系统的使用就是去读取/proc 目录下的这些文件,获取文件中记录的信息,可以直接使用 cat 命令读取,也可以在应用程序中调用 open()打开、然后再使用 read()函数读取。

第八章 信号:基础

事实上,在很多应用程序当中,都会存在处理异步事件这种需求,而信号提供了一种处理异步事件的方法

基本概念

信号是事件发生时对进程的通知机制,也可以把它称为软件中断
信号与硬件中断的相似之处在于能够打断程序当前执行的正常流程,其实是在软件层次上对中断机制的一种模拟

信号的目的是用来通信的
信号可以由“谁”发出呢?
⚫ 硬件发生异常,即硬件检测到错误条件并通知内核,随即再由内核发送相应的信号给相关进程。
⚫ 用于在终端下输入了能够产生信号的特殊字符。譬如在终端上按下 CTRL + C 组合按键可以产生中断信号(SIGINT),通过这个方法可以终止在前台运行的进程;按下 CTRL + Z 组合按键可以产生暂停信号(SIGCONT),通过这个方法可以暂停当前前台运行的进程。
⚫ 进程调用 kill()系统调用可将任意信号发送给另一个进程或进程组。
⚫ 发生了软件事件,即当检测到某种软件条件已经发生。进程所设置的定时器已经超时、进程执行的 CPU 时间超限、进程的某个子进程退出等等情况)

信号由谁处理、怎么处理
信号通常是发送给对应的进程,当信号到达后,该进程需要做出相应的处理措施
⚫ 忽略信号
⚫ 捕获信号。当信号到达进程后,执行预先绑定好的信号处理函数。Linux 系统提供了 signal()系统调用可用于注册信号的处理函数
⚫ 执行系统默认操作。进程不对该信号事件作出处理,而是交由系统进行处理,每一种信号都会有其对应的系统默认的处理方式

信号是异步的
程序是无法得知中断事件产生的具体时间
只有当产生中断事件时,才会告知程序、然后打断当前程序的正常执行流程、跳转去执行中断服务函数,这就是异步处理方式。

信号本质上是 int 类型数字编号
这些信号在<signum.h>头文件中定义,每个信号都是以 SIGxxx 开头
不存在编号为 0 的信号
信号编号是从 1 开始的,事实上 kill()函数对信号编号 0 有着特殊的应用

信号的分类

从可靠性方面将信号分为可靠信号与不可靠信号
从实时性方面将信号分为实时信号与非实时信号

可靠信号与不可靠信号
期 UNIX 系统中的信号机制比较简单和原始,后来在实践中暴露出一些问题
⚫ 进程每次处理信号后,就将对信号的响应设置为系统默认操作。在某些情况下,将导致对信号的错误处理;因此,用户如果不希望这样的操作,那么就要在信号处理函数结尾再一次调用 signal(),重新为该信号绑定相应的处理函数。
⚫ 早期 UNIX 下的不可靠信号主要指的是进程可能对信号做出错误的反应以及信号可能丢失(处理信号时又来了新的信号,则导致信号丢失)。

Linux 支持不可靠信号,但是对不可靠信号机制做了改进:在调用完信号处理函数后,不必重新调用signal()。
因此,Linux 下的不可靠信号问题主要指的是信号可能丢失。在 Linux 系统下,信号值小于 SIGRTMIN(34)的信号都是不可靠信号,这就是"不可靠信号"的来源
在这里插入图片描述编号 1-31 所对应的是不可靠信号,编号 34-64 对应的是可靠信号
可靠信号支持排队,不会丢失,同时,信号的发送和绑定也出现了新版本,信号发送函数 sigqueue()及信号绑定函数 sigaction()。

实时信号与非实时信号
非实
时信号都不支持排队,都是不可靠信号;实时信号都支持排队,都是可靠信号。
实时信号保证了发送的多个信号都能被接收,实时信号是 POSIX 标准的一部分,可用于应用进程。
一般我们也把非实时信号(不可靠信号)称为标准信号

常见信号与默认行为
⚫ SIGINT
当用户在终端按下中断字符(通常是 CTRL + C)时,内核将发送 SIGINT 信号给前台进程组中的每一
个进程。该信号的系统默认操作是终止进程的运行。所以通常我们都会使用 CTRL + C 来终止一个占用前台
的进程,原因在于大部分的进程会将该信号交给系统去处理,从而执行该信号的系统默认操作。
⚫ SIGQUIT
当用户在终端按下退出字符(通常是 CTRL + \)时,内核将发送 SIGQUIT 信号给前台进程组中的每一
个进程。该信号的系统默认操作是终止进程的运行、并生成可用于调试的核心转储文件。进程如果陷入无限
循环、或不再响应时,使用 SIGQUIT 信号就很合适。所以对于一个前台进程,既可以在终端按下中断字符
CTRL + C、也可以按下退出字符 CTRL + \来终止,当然前提条件是,此进程会将 SIGINT 信号或 SIGQUIT
信号交给系统处理(也就是没有将信号忽略或捕获),进入执行该信号所对应的系统默认操作。
⚫ SIGILL
如果进程试图执行非法(即格式不正确)的机器语言指令,系统将向进程发送该信号。该信号的系统默
认操作是终止进程的运行。
⚫ SIGABRT
当进程调用 abort()系统调用时(进程异常终止),系统会向该进程发送 SIGABRT 信号。该信号的系统
默认操作是终止进程、并生成核心转储文件。
⚫ SIGBUS
产生该信号(总线错误, bus error)表示发生了某种内存访问错误。该信号的系统默认操作是终止进程。
⚫ SIGFPE
该信号因特定类型的算术错误而产生,譬如除以 0。该信号的系统默认操作是终止进程。
⚫ SIGKILL
此信号为“必杀(sure kill)”信号,用于杀死进程的终极办法,此信号无法被进程阻塞、忽略或者捕
获,故而“一击必杀”,总能终止进程。使用 SIGINT 信号和 SIGQUIT 信号虽然能终止进程,但是前提条
件是该进程并没有忽略或捕获这些信号,如果使用 SIGINT 或 SIGQUIT 无法终止进程,那就使用“必杀信
号”SIGKILL 吧。Linux 下有一个 kill 命令,kill 命令可用于向进程发送信号,我们会使用"kill -9 xxx"命令
来终止一个进程(xxx 表示进程的 pid),这里的-9 其实指的就是发送编号为 9 的信号,也就是 SIGKILL 信
号。
⚫ SIGUSR1
该信号和 SIGUSR2 信号供程序员自定义使用,内核绝不会为进程产生这些信号,在我们的程序中,可
以使用这些信号来互通通知事件的发生,或是进程彼此同步操作。该信号的系统默认操作是终止进程。
⚫ SIGSEGV
这一信号非常常见,当应用程序对内存的引用无效时,操作系统就会向该应用程序发送该信号。引起对
内存无效引用的原因很多,C 语言中引发这些事件往往是解引用的指针里包含了错误地址(譬如,未初始化
的指针),或者传递了一个无效参数供函数调用等。该信号的系统默认操作是终止进程。
⚫ SIGUSR2
与 SIGUSR1 信号相同。
⚫ SIGPIPE
涉及到管道和 socket,当进程向已经关闭的管道、FIFO 或套接字写入信息时,那么系统将发送该信号
给进程。该信号的系统默认操作是终止进程。
⚫ SIGALRM
与系统调用 alarm()或 setitimer()有关,
应用程序中可以调用 alarm()或 setitimer()函数来设置一个定时器,
当定时器定时时间到,那么内核将会发送 SIGALRM 信号给该应用程序,关于 alarm()或 setitimer()函数的使
用,后面将会进行讲解。该信号的系统默认操作是终止进程。
⚫ SIGTERM
这是用于终止进程的标准信号,也是 kill 命令所发送的默认信号(kill xxx,xxx 表示进程 pid),有时
我们会直接使用"kill -9 xxx"显式向进程发送 SIGKILL 信号来终止进程,然而这一做法通常是错误的,精心
设计的应用程序应该会捕获 SIGTERM 信号、并为其绑定一个处理函数,当该进程收到 SIGTERM 信号时,
会在处理函数中清除临时文件以及释放其它资源,再而退出程序。如果直接使用 SIGKILL 信号终止进程,
从而跳过了 SIGTERM 信号的处理函数,通常 SIGKILL 终止进程是不友好的方式、是暴力的方式,这种方
式应该作为最后手段,应首先尝试使用 SIGTERM,实在不行再使用最后手段 SIGKILL。
⚫ SIGCHLD
当父进程的某一个子进程终止时,内核会向父进程发送该信号。当父进程的某一个子进程因收到信号而
停止或恢复时,内核也可能向父进程发送该信号。注意这里说的停止并不是终止,你可以理解为暂停。该信
号的系统默认操作是忽略此信号,如果父进程希望被告知其子进程的这种状态改变,则应捕获此信号。
⚫ SIGCLD
与 SIGCHLD 信号同义。
⚫ SIGCONT
将该信号发送给已停止的进程,进程将会恢复运行。当进程接收到此信号时并不处于停止状态,系统默
认操作是忽略该信号,但如果进程处于停止状态,则系统默认操作是使该进程继续运行。
⚫ SIGSTOP
这是一个“必停”信号,用于停止进程(注意停止不是终止,停止只是暂停运行、进程并没有终止),
应用程序无法将该信号忽略或者捕获,故而总能停止进程。
⚫ SIGTSTP
这也是一个停止信号,当用户在终端按下停止字符(通常是 CTRL + Z),那么系统会将 SIGTSTP 信号
发送给前台进程组中的每一个进程,使其停止运行。
⚫ SIGXCPU
当进程的 CPU 时间超出对应的资源限制时,内核将发送此信号给该进程。
⚫ SIGVTALRM
应用程序调用 setitimer()函数设置一个虚拟定时器,当定时器定时时间到时,内核将会发送该信号给进
程。
⚫ SIGWINCH
在窗口环境中,当终端窗口尺寸发生变化时(譬如用户手动调整了大小,应用程序调用 ioctl()设置了大
小等),系统会向前台进程组中的每一个进程发送该信号。
⚫ SIGPOLL/SIGIO
这两个信号同义。这两个信号将会在高级 IO 章节内容中使用到,用于提示一个异步 IO 事件的发生,
譬如应用程序打开的文件描述符发生了 I/O 事件时,内核会向应用程序发送 SIGIO 信号。
⚫ SIGSYS
如果进程发起的系统调用有误,那么内核将发送该信号给对应的进程。
在这里插入图片描述

进程对信号的处理

Linux 系统提供了系统调用 signal()和 sigaction()两个函数用于设置信号的处理方式

#include <signal.h>
typedef void (*sig_t)(int);
sig_t signal(int signum, sig_t handler);

signal()函数是 Linux 系统下设置信号处理方式最简单的接口,可将信号的处理方式设置为捕获信号、忽略信号以及系统默认操作

参数 handler 既可以设置为用户自定义的函数,也就是捕获信号时需要执行的处理函数,也可以设置为 SIG_IGN 或 SIG_DFL, SIG_IGN 表示此进程需要忽略该信号, SIG_DFL 则表示设置为系统默认操作。

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
static void sig_handler(int sig)
{
printf("Received signal: %d\n", sig);
}

nt main(int argc, char *argv[])
{
	sig_t ret = NULL;
	ret = signal(SIGINT, (sig_t)sig_handler);
	if (SIG_ERR == ret) {
	perror("signal error");
	exit(-1);
	}

for ( ; ; ) { }
exit(0);
}

当运行程序之后,程序会占用终端称为一个前台进程,此时按下中断符便会打印出信息(^C 表示按下了中断符)
平时大家使用 CTRL + C 可以终止一个进程,而这里却不能通过这种方式来终止这个测试程序,原因在于测试程序中捕获了该信号,而对应的处理方式仅仅只是打印一条语句、而并不终止进程

于是想要终止进程就kill
Tips:普通用户只能杀死该用户自己的进程,无权限杀死其它用户的进程。在这里插入图片描述
两种不同状态下信号的处理方式
如果程序中没有调用 signal()函数为信号设置相应的处理方式,亦或者程序刚启动起来并未运行到 signal()处,那么这时进程接收到一个信号后是如何处理的呢?
⚫ 程序启动
当一个应用程序刚启动的时候(或者程序中没有调用 signal()函数),通常情况下,进程对所有信号的处理方式都设置为系统默认操作。

⚫ 进程创建
当一个进程调用 fork()创建子进程时,其子进程将会继承父进程的信号处理方式,因为子进程在开始时复制了父进程的内存映像,所以信号捕获函数的地址在子进程中是有意义的。

除了 signal()之外,sigaction()系统调用是设置信号处理方式的另一选择,
事实上,推荐大家使用 sigaction()函数。
sigaction()允许单独获取信号的处理函数而不是设置,并且还可以设置各种属性对调用信号处理函数时的行为施以更加精准的控制

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

signum:需要设置的信号,除了 SIGKILL 信号和 SIGSTOP 信号之外的任何信号。
act:act 参数是一个 struct sigaction 类型指针,指向一个 struct sigaction 数据结构,该数据结构描述了信号的处理方式。如果参数 act 不为 NULL,则表示需要为信号设置新的处理方式;如果参数 act 为 NULL,则表示无需改变信号当前的处理方式。
。如果参数
oldact 不为 NULL,则会将信号之前的处理方式等信息通过参数 oldact 返回出来

struct sigaction 结构体

struct sigaction {
	void (*sa_handler)(int);
	void (*sa_sigaction)(int, siginfo_t *, void *);
	sigset_t sa_mask;
	int sa_flags;
	void (*sa_restorer)(void);
};

⚫ sa_handler:指定信号处理函数,与 signal()函数的 handler 参数相同。
⚫ sa_sigaction:也用于指定信号处理函数,他提供了更多的参数,可以通过该函数获取到更多信息
sa_handler 和sa_sigaction 是互斥的,不能同时设置,对于标准信号来说,使用 sa_handler 就可以了,可通过下面的sa_flags标志位来设置SA_SIGINFO 标志进行选择。
⚫sa_mask:参数 sa_mask 定义了一组信号,当进程在执行由 sa_handler 所定义的信号处理函数之前,会先将这组信号添加到进程的信号掩码字段中,当进程执行完处理函数之后再恢复信号掩码,将这组信号从信号掩码字段中删除。

如果进程接收到了信号掩码中的这些信号,那么这个信号将会被阻塞暂时不能得到处理,直到这些信号从进程的信号掩码中移除。
进程会自动将当前处理的信号添加到信号掩码字段中,这样保证了在处理一个给定的信号时,如果此信号再次发生,那么它将会被阻塞。如果用户还需要在阻塞其它的信号,则可以通过设置参数 sa_mask 来完成

⚫ sa_restorer:该成员已过时,不要再使用了。
⚫ sa_flags:参数 sa_flags 指定了一组标志,这些标志用于控制信号的处理过程
SA_NOCLDSTOP
如果 signum 为 SIGCHLD,则子进程停止时(即当它们接收到 SIGSTOP、 SIGTSTP、SIGTTIN 或 SIGTTOU中的一种时)或恢复(即它们接收到 SIGCONT)时不会收到 SIGCHLD 信号。
SA_NOCLDWAIT
如果 signum 是 SIGCHLD,则在子进程终止时不要将其转变为僵尸进程
SA_NODEFER
默认情况下,我们期望进程在处理一个信号时阻塞同种信号,否则引起一些竞态条件;如果设置了 SA_NODEFER 标志,则表示不对它进行阻塞。
SA_RESETHAND
执行完信号处理函数之后,将信号的处理方式设置为系统默认操作。
SA_RESTART
被信号中断的系统调用,在信号处理完成之后将自动重新发起。
SA_SIGINFO
如果设置了该标志,则表示使用 sa_sigaction 作为信号处理函数,该标志为实际数字是0

在这里插入图片描述

向进程发送信号

kill()函数
kill()系统调用可将信号发送给指定的进程或进程组中的每一个进程

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

返回值:成功返回 0;失败将返回-1,并设置 errno。
参数 pid 不同取值含义:
⚫ 如果 pid 为正,则信号 sig 将发送到 pid 指定的进程。
⚫== 如果 pid 等于 0,则将 sig 发送到当前进程的进程组中的每个进程。==
⚫ 如果 pid 等于-1,则将 sig 发送到当前进程有权发送信号的每个进程,但进程 1(init)除外。
⚫ 如果 pid 小于-1,则将 sig 发送到 ID 为-pid 的进程组中的每个进程。

基本规则是发送者进程的实际用户 ID 或有效用户 ID 必须等于接收者进程的实际用户 ID 或有效用户 ID。
超级用户root 进程可以将信号发送给任何进程

从上面介绍可知,当 sig 为 0 时,仍可进行正常执行的错误检查,但不会发送信号,
这通常可用于确定一个特定的进程是否存在,如果向一个不存在的进程发送信号, kill()将会返回-1, errno 将被设置为ESRCH,表示进程不存在。

raise()
有时进程需要向自身发送信号,raise()函数可用于实现这一要求

#include <signal.h>
int raise(int sig);
等价于kill(getpid(), sig);

alarm()和 pause()函数

使用 alarm()函数可以设置一个定时器(闹钟),当定时器定时时间到时,内核会向进程发送 SIGALRM

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

返回值:如果在调用 alarm()时,之前已经为该进程设置了 alarm 闹钟还没有超时,则该闹钟的剩余值作为本次 alarm()函数调用的返回值.否则返回 0

需要注意的是 alarm 闹钟并不能循环触发,只能触发一次,若想要实现循环触发,可以在 SIGALRM 信号处理函数中再次调用 alarm()函数设置定时器。

pause()系统调用可以使得进程暂停运行、进入休眠状态,直到进程捕获到一个信号为止,只有执行了信号处理函数并从其返回时,pause()才返回

信号集

信号集其实就是 sigset_t 类型数据结构

# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} sigset_t;

初始化信号集
**sigemptyset()和 sigfillset()**用于初始化信号集。
sigemptyset()初始化信号集,使其不包含任何信号;
而sigfillset()函数初始化信号集,使其包含所有信号(包括所有实时信号)
分别使用 **sigaddset()和 sigdelset()**函数向信号集中添加或移除一个信号
使用 sigismember()函数可以测试某一个信号是否在指定的信号集中

在 Linux 下,每个信号都有一串与之相对应的字符串描述信息,用于对该信号进行相应的描述。
sys_siglist 数组是一个 char *类型的数组,数组中的每一个元素存放的是一个字符串指针,指向一个信号描述信息。
使用 **sys_siglist[SIGINT]**来获取对 SIGINT 信号的描述

除了直接使用 sys_siglist 数组获取描述信息之外,还可以使用 strsignal()函数.更推荐使用 strsignal()函数
调用 strsignal()函数将会获取到参数 sig 指定的信号对应的描述信息,返回该描述信息字符串的指针;
函数会对参数 sig 进行检查,若传入的 sig 无效,则会返回"Unknown signal"信息。

信号掩码(阻塞信号传递)

内核为每一个进程维护了一个信号掩码
当进程接收到一个属于信号掩码中定义的信号时,该信号将会被阻塞、无法传递给进程进行处理,那么内核会将其阻塞,直到该信号从信号掩码中移除,内核才会把该信号传递给进程从而得到处理。

向信号掩码中添加一个信号,通常有如下几种方式:
⚫ 当应用程序调用 signal()或 sigaction()函数为某一个信号设置处理方式时,进程会自动将该信号添加到信号掩码中
当信号处理函数结束返回后,会自动将该信号从信号掩码中移除。
⚫ 使用 sigaction()函数为信号设置处理方式时,可以额外指定一组信号.,当调用信号处理函数时将该组信号自动添加到信号掩码中,当信号处理函数结束返回后,再将这组信号从信号掩码中移除
⚫ 还可以使用 sigprocmask()系统调用,随时可以显式地向信号掩码中添加/移除信号。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

函数参数和返回值含义如下:
how:参数 how 指定了调用函数时的一些行为。 set:将参数 set 指向的信号集内的所有信号添加到信号掩码中或者从信号掩码中移除;如果参数 set 为NULL,则表示无需对当前信号掩码作出改动。
参数 how 可以设置为以下宏:
⚫ SIG_BLOCK:将参数 set 所指向的信号集内的所有信号添加到进程的信号掩码中。换言之,将信号掩码设置为当前值与 set 的并集。
⚫ SIG_UNBLOCK:将参数 set 指向的信号集内的所有信号从进程信号掩码中移除。
⚫ SIG_SETMASK:进程信号掩码直接设置为参数 set 指向的信号集。

阻塞等待信号 sigsuspend()

恢复信号掩码和 pause()挂起进程这两个动作封装成一个原子操作,这正是 sigsuspend()
调用 sigsuspend()函数相当于以不可中断(原子操作)的方式执行以下操作

sigprocmask(SIG_SETMASK, &mask, &old_mask);
pause();
sigprocmask(SIG_SETMASK, &old_mask, NULL);

实时信号

为了确定进程中处于等待状态的是哪些信号,可以使用 sigpending()函数获取

发送实时信号
等待信号集只是一个掩码,仅表明一个信号是否发生,而不能表示其发生的次数
实时信号较之于标准信号,其优势如下:
⚫ 实时信号的信号范围有所扩大,可应用于应用程序自定义的目的,而标准信号仅提供了两个信号可用于应用程序自定义使用:SIGUSR1 和 SIGUSR2。
⚫ 内核对于实时信号所采取的是队列化管理。如果将某一实时信号多次发送给另一个进程,那么将会多次传递此信号。
⚫ 当发送一个实时信号时,可为信号指定伴随数据(一整形数据或者指针值),供接收信号的进程在它的信号处理函数中获取。
⚫ 不同实时信号的传递顺序得到保障。如果有多个不同的实时信号处于等待状态,那么将率先传递具有最小编号的信号。换言之,信号的编号越小,其优先级越高,如果是同一类型的多个信号在排队,那么信号(以及伴随数据)的传递顺序与信号发送来时的顺序保持一致。

Linux 内核定义了 31 个不同的实时信号,信号编号范围为 34~64,使用 SIGRTMIN 表示编号最小的实时信号,使用 SIGRTMAX 表示编号最大的实时信号,其它信号编号可使用这两个宏加上一个整数或减去一个整数。

应用程序当中使用实时信号,需要有以下的两点要求:
⚫ 发送进程使用 sigqueue()系统调用向另一个进程发送实时信号以及伴随数据
⚫ 接收实时信号的进程要为该信号建立一个信号处理函数,使用sigaction函数为信号建立处理函数,并加入 SA_SIGINFO,这样信号处理函数才能够接收到实时信号以及伴随数据
要使用sa_sigaction 指针指向的处理函数,而不是 sa_handler,当然允许应用程序使用 sa_handler,但这样就不能获取到实时信号的伴随数据了。

异常退出 abort()函数

异常退出程序,则一般使用 abort()库函数,使用 abort()终止进程运行,会生成核心转储文件

进程

进程与程序

操作系统下的应用程序在运行 main()函数之前需要先执行一段引导代码,最终由这段引导代码去调用应用程序的 main()函数
我们在编写应用程序的时候,不用考虑引导代码的问题,在编译链接时,由链接器将引导代码链接到我们的应用程序当中,一起构成最终的可执行文件。
加载器是操作系统中的程序,当执行程序时,加载器负责将此应用程序加载内存中去执行。

流程
命令行参数 -> shell进程 -> 加载器 -> 引导代码 -> main函数

程序如何结束?
正常终止和异常终止
正常终止包括:
⚫ main()函数中通过 return 语句返回来终止进程;
⚫ 应用程序中调用 exit()函数终止进程;
⚫ 应用程序中调用_exit()或_Exit()终止进程;
以上这些是在前面的课程中给大家介绍的,异常终止包括:
⚫ 应用程序中调用 abort()函数终止进程;
⚫ 进程接收到一个信号,譬如 SIGKILL 信号。

注册进程终止处理函数 atexit()
atexit()库函数用于注册一个进程在正常终止时要调用的函数

何为进程?
进程其实就是一个可执行程序的实例。可执行程序就是一个可执行文件。静态的概念,存放磁盘中
当它被运行之后,它将会对系统环境产生一定的影响,所以可执行程序的实例就是可执行文件被运行。
当应用程序被加载到内存中运行之后它就称为了一个进程

系统调用 getpid()来获取本进程的进程号
使用 getppid()系统调用获取父进程的进程号

进程的环境变量

每一个进程都有一组与其相关的环境变量在这里插入图片描述
shell中获取环境变量
使用 export 命令还可以添加一个新的环境变量或删除一个环境变量
使用"export -n LINUX_APP"命令则可以删除 LINUX_APP 环境变量

应用程序中获取环境变量
进程的环境变量是从其父进程中继承过来的,譬如在 shell 终端下执行一个应用程序,那么该进程的环境变量就是从其父进程(shell 进程)中继承过来的。新的进程在创建之前,会继承其父进程的环境变量副本。

环境变量存放在一个字符串数组中,在应用程序中,通过 environ 变量指向它,environ 是一个全局变量,在我们的应用程序中只需申明它即可使用

extern char **environ; // 申明外部全局变量 environ
/* 打印进程的环境变量 */
for (i = 0; NULL != environ[i]; i++)
puts(environ[i]);

获取指定环境变量 getenv()

添加/删除/修改环境变量putenv()、setenv()、unsetenv()、clearenv()
推荐大家使用 setenv()函数,这样使用自动变量作为 setenv()的参数也不会有问题

环境变量的作用
环境变量常见的用途之一是在 shell 中,每一个环境变量都有它所表示的含义
USER 环境变量表示当前用户名,SHELL 环境变量表示 shell 解析器名称,PWD 环境变量表示当前所在目录等

进程的内存布局

⚫ 正文段。也可称为代码段
⚫ 初始化数据段。通常将此段称为数据段,包含了显式初始化的全局变量和静态变量
⚫ 未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为 bss 段
在程序开始执行之前,系统会将本段内所有内存初始化为 0
⚫ 栈。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中
⚫ 堆。可在运行时动态进行内存分配的一块区域
在这里插入图片描述

进程的虚拟地址空间

每一个进程都在自己独立的地址空间中运行
在 32 位系统中,每个进程的逻辑地址空间均为 4GB
在这里插入图片描述
虚拟地址会通过硬件 MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU 会将物理地址“翻译”为对应的物理地址
在这里插入图片描述

Linux 系统下,应用程序运行在一个虚拟地址空间中,所以程序中读写的内存地址对应也是虚拟地址,并不是真正的物理地址,譬如应用程序中读写 0x80800000 这个地址,实际上并不对应于硬件0x80800000这个物理地址。

为什么需要引入虚拟地址呢?
计算机物理内存的大小是固定的
⚫ 当多个程序需要运行时,必须保证这些程序用到的内存总量要小于计算机实际的物理内存的大小。
⚫ 内存使用效率低。内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存。然而由于大量的数据装入装出,内存的使用效率就会非常低。
⚫ 进程地址空间不隔离。由于程序是直接访问物理内存的,所以每一个进程都可以修改其它进程的内存数据,甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定。
⚫ 无法确定程序的链接地址。程序运行时,链接地址和运行地址必须一致,否则程序无法运行!因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的。

所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点:
⚫ 进程与进程、进程与内核相互隔离。提高了系统的安全性与稳定性
⚫ 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。
⚫ 便于实现内存保护机制。
⚫ 编译应用程序时,无需关心链接地址。

fork()创建子进程

关于子进程
子进程被创建出来之后,便是一个独立的进程,拥有自己独立的进程空间,系统内唯一的进程号,拥有自己独立的 PCB(进程控制块),子进程会被内核同等调度执行,参与到系统的进程调度中。

Tips:系统调度。Linux 系统是一个多任务、多进程、多线程的操作系统,系统是这样做的,每一个进程(或线程)执行一段固定的时间,时间到了之后切换执行下一个进程或线程,依次轮流执行,这就称为调度,由操作系统负责这件事情

父、子进程间的文件共享
在这里插入图片描述
系统调用 vfork()
效率高,但是容易出bug
vfork()与 fork()函数主要有以下两个区别:
⚫ vfork()与 fork()一样都创建了子进程,但 vfork()函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或_exit),于是也就不会引用该地址空间的数据。
不过在子进程调用 exec 或_exit 之前,它在父进程的空间中运行、子进程共享父进程的内存。这种优化工作方式的实现提高的效率;但如果子进程修改了父进程的数据(除了 vfork 返回值的变量)、进行了函数调用、或者没有调用 exec 或_exit 就返回将可能带来未知的结果。
⚫ 另一个区别在于,vfork()保证子进程先运行,子进程调用 exec 之后父进程才可能被调度运行。

除非速度绝对重要的场合,
我们的程序当中应舍弃 vfork()而使用 fork()。

fork()之后的竞争条件

如果要让子进程先运行,则可使父进程被阻塞,等到子进程来唤醒它

进程的诞生
进程号为 1 的进程便是所有进程的父进程,通常称为 init 进程,它是 Linux 系统启动之后运行的第一个进程,它管理着系统上所有其它进程,init 进程是由内核启动,因此理论上说它没有父进程。

进程的终止
正常、异常
_exit()函数和 exit()函数的 status 参数定义了进程的终止状态(termination status),父进程可以调用 wait()函数以获取该状态。

在我们的程序当中,一般使用 exit()库函数而非_exit()系统调用,原因在于 exit()最终也会通过_exit()终止进程,但在此之前,它将会完成一些其它的工作,exit()函数会执行的动作如下:
⚫ 如果程序中注册了进程终止处理函数,那么会调用终止处理函数。
刷新 stdio 流缓冲区。关于 stdio 流缓冲区的问题,稍后编写一个简单地测试程序进行说明;
⚫ 执行_exit()系统调用。

当然一般推荐的是子进程使用_exit()退出、而父进程则使用 exit()退出。其原因就在于通过 fork()创建子进程时会复制stdio缓冲区。调用 exit()函数终止进程时会刷新进程的 stdio 缓冲区。

可以采用以下任一方法来避免重复的输出结果:
⚫ 对于行缓冲设备,可以加上对应换行符,譬如 printf 打印输出字符串时在字符串后面添加\n 换行符,对于 puts()函数来说,本身会自动添加换行符;
⚫ 在调用 fork()之前,使用函数 fflush()来刷新 stdio 缓冲区,当然,作为另一种选择,也可以使用setvbuf()和 setbuf()来关闭 stdio 流的缓冲功能
⚫ 子进程调用_exit()退出进程、而非使用 exit(),调用_exit()在退出时便不会刷新 stdio 缓冲区

监视子进程

在很多应用程序的设计中,父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息

wait()函数 waitpid()函数
使用 wait()系统调用存在着一些限制,这些限制包括如下:
⚫ 如果父进程创建了多个子进程,使用 wait()将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁;
⚫ 如果子进程没有终止,正在运行,那么 wait()总是保持阻塞,有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知;
⚫ 使用 wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如 SIGSTOP 信号)而停止(注意,这里停止指的暂停运行),或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了。

而设计 waitpid()则可以突破这些限制

#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);

函数参数和返回值含义如下:
pid:参数 pid 用于表示需要等待的某个具体子进程,关于参数 pid 的取值范围如下:
⚫ 如果 pid 大于 0,表示等待进程号为 pid 的子进程;
⚫ 如果 pid 等于 0,则等待与调用进程(父进程)同一个进程组的所有子进程;
⚫ 如果 pid 小于-1,则会等待进程组标识符与 pid 绝对值相等的所有子进程;
⚫ 如果 pid 等于-1,则等待任意子进程。wait(&status)与 waitpid(-1, &status, 0)等价。

参数 options 是一个位掩码,可以包括 0 个或多个如下标志:
⚫ WNOHANG:如果子进程没有发生状态改变(终止、暂停),则立即返回,也就是执行非阻塞等待,可以实现轮训 poll,通过返回值可以判断是否有子进程发生状态改变,若返回值等于 0 表示没有发生改变。
⚫ WUNTRACED:除了返回终止的子进程的状态信息外,还返回因信号而停止(暂停运行)的子进程状态信息
⚫ WCONTINUED:返回那些因收到 SIGCONT 信号而恢复运行的子进程的状态信息。

**status:**与 wait()函数的 status 参数意义相同。

waitid()提供了更多的扩展功能

僵尸进程与孤儿进程

孤儿进程

父进程先于子进程结束,也就是意味着,此时子进程变成了一个“孤儿”,我们把这种进程就称为孤儿进程
在 Linux 系统当中,所有的孤儿进程都自动成为 init 进程(进程号为 1)的子进程

Tips:/sbin/upstart 进程Ubuntu 系统图形化界面有关系,是图形化界面下的一个后台守护进程,可负责“收养”孤儿进程所以图形化界面下,upstart 进程1911就自动成为了孤儿进程的父进程

僵尸进程

进程结束之后,通常需要其父进程为其“收尸”,回收子进程占用的一些内存资源,父进程通过调用wait()(或其变体 waitpid()、waitid()等)函数回收子进程资源,归还给系统。
当父进程调用 wait() (或其变体,下文不再强调)为子进程“收尸”后,僵尸进程就会被内核彻底删除。
另外一种情况,如果父进程并没有调用 wait()函数然后就退出了,那么此时 init 进程将会接管它的子进程并自动调用 wait(),故而从系统中移除僵尸进程。

危害!!!

  • 如果系统中存在大量的僵尸进程,它们势必会填满内核进程表,从而阻碍新进程的创建 僵尸进程是无法通过信号将其杀死的,即使是“一击必杀”信号
    SIGKILL 也无法将其杀死 那么这种情况下,只能杀死僵尸进程的 父进程(或等待其父进程终止),这样 init
    进程将会接管这些僵尸进程,从而将它们从系统中清理掉!

使用命令"ps -aux"可以查看到该僵尸进程

SIGCHLD 信号
⚫ 当父进程的某个子进程终止时,父进程会收到 SIGCHLD 信号;
⚫ 当父进程的某个子进程因收到信号而停止(暂停运行)或恢复时,内核也可能向父进程发送该信号。

子进程的终止属于异步事件,父进程事先是无法预知的
它不能一直wait()阻塞等待子进程终止(或轮训)
通过 SIGCHLD 信号解决

不过,使用这一方式时需要掌握一些窍门!

while (waitpid(-1, NULL, WNOHANG) > 0)
continue;

在 SIGCHLD 信号处理函数中循环以非阻塞方式来调用 waitpid(),直至再无其它终止的子进程需要处理为止。
当然这样做也只能执行一次处理

执行新程序

exec 函数来实现运行另一个新的程序

execve()函数
系统调用 execve()可以将新程序加载到某一进程的内存空间
将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的 main()函数开始执行

#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);

argv:参数 argv 则指定了传递给新程序的命令行参数。是一个字符串数组,该数组对应于 main(int argc,char *argv[])函数的第二个参数 argv,且格式也与之相同,是由字符串指针所组成的数组,以 NULL 结束。argv[0]对应的便是新程序自身路径名。
envp:参数 envp 也是一个字符串指针数组,指定了新程序的环境变量列表,参数 envp 其实对应于新程序的 environ 数组,同样也是以 NULL 结束,所指向的字符串格式name=value。

基于系统调用 execve(),还提供了一系列以 exec 为前缀命名的库函数,虽然函数参数各异,当其功能相同,通常将这些函数(包括系统调用 execve())称为 exec 族函数,所以exec 函数并不是指某一个函数、而是 exec 族函数

通常将调用这些 exec 函数加载一个外部新程序的过程称为 exec 操作。

代码例子:旧程序

char *arg_arr[5];
char *env_arr[5] = {"NAME=app", "AGE=25","SEX=man", NULL};
if (2 > argc)
exit(-1);

arg_arr[0] = argv[1];
arg_arr[1] = "Hello";
arg_arr[2] = "World";
arg_arr[3] = NULL;
execve(argv[1], arg_arr, env_arr);

新程序

char **ep = NULL;
int j;

for (j = 0; j < argc; j++)
printf("argv[%d]: %s\n", j, argv[j]);
puts("env:");
for (ep = environ; *ep != NULL; ep++)
printf(" %s\n", *ep);

exec 库函数
exec 族函数包括多个不同的函数,这些函数命名都以 exec 为前缀
execl()、 execlp()、 execle()、 execv()、execvp()、execvpe()

extern char **environ;
int execl(const char *path, const char *arg, ... /* (char *) NULL */);
int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

接下来简单地介绍下它们之间的区别:
⚫ execl()和 execv()都是基本的 exec 函数,都可用于执行一个新程序。不同的在于第二个参数,execv()的argv 参数与 execve()的 argv 参数相同,也是字符串指针数组;而 execl()把参数列表依次排列,使用可变参数形式传递,本质上也是多个字符串,以 NULL 结尾

// execv 传参
char *arg_arr[5];
arg_arr[0] = "./newApp";
arg_arr[1] = "Hello";
arg_arr[2] = "World";
arg_arr[3] = NULL;
execv("./newApp", arg_arr);
// execl 传参
execl("./newApp", "./newApp", "Hello", "World", NULL);

⚫ execlp()和 execvp()在 execl()和 execv()基础上加了一个 p,这个 p 其实表示的是 PATH
execl()和execv()要求提供新程序的路径名,而 execlp()和 execvp()则允许只提供新程序文件名,系统会在由环境变量 PATH 所指定的目录列表中寻找相应的可执行文件,如果执行的新程序是一个 Linux 命令,这将很有用;当然,execlp()和 execvp()函数也兼容相对路径和绝对路径的方式。
⚫ execle()和 execvpe()这两个函数在命名上加了一个 e,这个 e 其实表示的是 environment 环境变量,意味着这两个函数可以指定自定义的环境变量列表给新程序(感觉没啥区别)

使用以上给大家介绍的 6 个 exec 库函数运行 ls 命令,并加入参数-a 和-l。
arg_arr[0] = "ls";
arg_arr[1] = "-a";
arg_arr[2] = "-l";
arg_arr[3] = NULL;
extern char **environ;

execl("/bin/ls", "ls", "-a", "-l", NULL);//l列参
execv("/bin/ls", arg_arr);							//v普通数组
execlp("ls", "ls", "-a", "-l", NULL);		//l列参,p路径
execvp("ls", arg_arr);									//p路径
execle("/bin/ls", "ls", "-a", "-l", NULL, environ);	//e环境
execvpe("ls", arg_arr, environ);					//v普通数组。p路径,e环境

使用 system()函数可以很方便地在我们的程序当中执行任意 shell 命令

#include <stdlib.h>
int system(const char *command);

system("ls -la")
system("echo HelloWorld")

进程状态与进程关系

进程状态
Linux 系统下进程通常存在 6 种不同的状态,分为:就绪态、运行态、僵尸态、可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)以及暂停态。
⚫ 就绪态(Ready):指该进程满足被 CPU 调度的所有条件但此时并没有被调度执行。当一个进程的时间片到达,操作系统调度程序会从就绪态链表中调度一个进程;
⚫ 运行态:指该进程当前正在被 CPU 调度运行
⚫ 僵尸态:僵尸态进程其实指的就是僵尸进程,指该进程已经结束、但其父进程还未给它“收尸”;
⚫ 可中断睡眠状态:可中断睡眠也称为浅度睡眠,表示睡的不够“死”,还可以被唤醒,一般来说可以通过信号来唤醒;
⚫ 不可中断睡眠状态:不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,但能等待相应的条件成立才能结束睡眠状态。浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态
⚫ 暂停态:暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停。处于暂停态的进程是可以恢复进入到就绪态的。
在这里插入图片描述
进程关系
以 init 进程为根的进程家族树;当子进程终止时,父进程会得到通知并能取得子进程的退出状态。

除此之外,进程间还存在着其它一些层次关系,譬如进程组和会话
由此可知,进程间存在着多种不同的关系,主要包括:无关系(相互独立)、父子进程关系、进程组以及会话。

3、进程组
Linux 系统设计进程组实质上是为了方便对进程进行管理。假设为了完成一个任务,需要并发运行 100个进程,但当处于某种场景时需要终止这 100 个进程,若没有进程组就需要一个一个去终止,这样非常麻烦且容易出现一些问题;有了进程组的概念之后,就可以将这 100 个进程设置为一个进程组,这些进程共享一个进程组 ID,这样一来,终止这 100 个进程只需要终止该进程组即可。

关于进程组需要注意以下以下内容:
⚫ 每个进程必定属于某一个进程组、且只能属于一个进程组
⚫ 每一个进程组有一个组长进程,组长进程的 ID 就等于进程组 ID
⚫ 在组长进程的 ID 前面加上一个负号即是操作进程组
⚫ 组长进程不能再创建新的进程组;
⚫ 一个进程组可以包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组;这与其组长进程是否终止无关;
⚫ 默认情况下,新创建的进程会继承父进程的进程组 ID。

通过系统调用 getpgrp()或 getpgid()可以获取进程对应的进程组 ID,
getpgrp()就等价于 getpgid(0)。

调用系统调用 setpgid()或 setpgrp()可以加入一个现有的进程组或创建一个新的进程组
setpgid()函数将参数 pid 指定的进程的进程组 ID 设置为参数 gpid。
如果这两个参数相等(pid==gpid),则由 pid 指定的进程变成为进程组的组长进程,创建了一个新的进程;
如果参数 pid 等于 0,则使用调用者的进程 ID;另外,如果参数 gpid 等于 0,则创建一个新的进程组,由参数 pid 指定的进程作为进程组组长进程。

4、会话
会话是一个或多个进程组的集合
在这里插入图片描述
一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它的是后台进程组;
每个会话都有一个会话首领(leader),即创建会话的进程

一个会话可以有控制终端、也可没有控制终端,
在有控制终端的情况下也只能连接一个控制终端,这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(譬如通过 SSH 协议网络登录)
会话的首领进程连接一个终端之后,该终端就成为会话的控制终端,会话首领进程被称为控制进程

产生在终端上的输入和信号将发送给会话的前台进程组中的所有进程,譬如 Ctrl + C (产生 SIGINT 信号)、Ctrl + Z(产生 SIGTSTP 信号)、Ctrl + \(产生 SIGQUIT 信号)等等这些由控制终端产生的信号。

当用户在某个终端登录时,一个新的会话就开始了

对于会话来说,会话的首领进程的进程组 ID 将作为该会话的标识,也就是会话 ID(sid),在默认情况下,新创建的进程会继承父进程的会话 ID。

通过系统调用 getsid()可以获取进程的会话 ID
使用系统调用 setsid()可以创建一个会话
如果调用者进程不是进程组的组长进程,调用 setsid()将创建一个新的会话。调用者进程是新会话的首领进程,同样也是一个新的进程组的组长进程,调用 setsid()创建的会话将没有控制终端。

守护进程

守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生
⚫ 长期运行。守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。
与控制终端脱离。在 Linux 中,系统与用户交互的界面称为终端

守护进程是一种很有用的进程。Linux 中大多数服务器就是用守护进程实现的
譬如,Internet 服务器inetd、Web 服务器 httpd 等。同时,守护进程完成许多系统任务,譬如作业规划进程 crond 等

守护进程 Daemon,通常简称为 d,一般进程名后面带有 d 就表示它是一个守护进程
通过命令"ps -ajx"查看系统所有的进程
在这里插入图片描述TTY 一栏是问号?表示该进程没有控制终端,也就是守护进程
COMMAND 一栏使用中括号[]括起来的表示内核线程,这些线程是在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常采用 k 开头的名字,表示 Kernel。

编写守护进程程序

  1. 创建子进程、终止父进程
    父进程调用 fork()创建子进程,然后父进程使用 exit()退出

  2. 子进程调用 setsid 创建会话
    在调用 fork 函数时,子进程继承了父进程的会话、进程组、控制终端等,虽然父进程退出了,但原先的会话期、进程组、控制终端等并没有改变,因此,那还不是真正意义上使两者独立开来。setsid 函数能够使子进程完全独立出来,从而脱离所有其他进程的控制。

调用 setsid 有三个作用:让子进程摆脱原会话的控制、让子进程摆脱原进程组的控制和让子进程摆脱原控制终端的控制。(保证子进程不是一个进程组的组长进程)

  1. 将工作目录更改为根目录
    子进程是继承了父进程的当前工作目录,由于在进程运行中,当前目录所在的文件系统是不能卸载的
    因此通常的做法是让“/”作为守护进程的当前目录,当然也可以指定其它目录来作为守护进程的工作目录。

  2. 重设文件权限掩码 umask
    通常的使用方法为 umask(0)。

  3. 关闭不再需要的文件描述符
    子进程继承了父进程的所有文件描述符,这些被打开的文件可能永远不会被守护进程读或写,但它们一样消耗系统资源。所以必须关闭这些文件

  4. 将文件描述符号为 0、1、2 定位到/dev/null
    将守护进程的标准输入、标准输出以及标准错误重定向到/dev/null,这使得守护进程的输出无处显示、也无处从交互式用户那里接收输入。
    /dev/null 是一个黑洞文件,自然是看不到输出信息。

  5. 其它:忽略 SIGCHLD 信号
    在 Linux 下,可以将 SIGCHLD 信号的处理方式设置为SIG_IGN,也就是忽略该信号,可让内核将僵尸进程转交给 init 进程去处理,这样既不会产生僵尸进程、又省去了服务器进程回收子进程所占用的时间。

守护进程一般以单例模式运行

SIGHUP 信号
当用户准备退出会话时,系统向该会话发出 SIGHUP 信号,会话将 SIGHUP 信号发送给所有子进程,子进程接收到 SIGHUP 信号后,便会自动终止,当所有会话中的所有进程都退出时,会话也就终止了

当程序当中忽略 SIGHUP 信号之后,进程不会随着终端退出而退出。但此时它已经变成了守护进程,脱离了控制终端

事实上,控制终端只是会话中的一个进程,只有会话中的所有进程退出后,会话才会结束;很显然当程序中忽略了 SIGHUP 信号,导致该进程不会终止,所以会话也依然会存在,从上图可知,其会话 ID 等于 23601,但此时会话已经没有控制终端了。

单例模式运行

通常情况下,一个程序可以被多次执行,即程序在还没有结束的情况下,又再次执行该程序,也就是系统中同时存在多个该程序的实例化对象(进程)

但对于有些程序设计来说,不允许出现这种情况,程序只能被执行一次,只要该程序没有结束,就无法再次运行,我们把这种情况称为单例模式运行。譬如系统中守护进程,这些守护进程一般都是服务器进程。

通过文件存在与否进行判断
代码中以 O_RDONLY | O_CREAT | O_EXCL 的方式打开文件,如果文件不存在则创建文件,如果文件存在则 open 会报错返回-1;使用 atexit 注册进程终止处理函数,当程序退出时,使用 remove()删除该文件。

问题主要包括如下三个方面:
⚫ 程序异常退出。程序异常同样无法执行到进程终止处理函数 delete_file(),同样将导致无法删除这个特定的文件;
计算机掉电关机。这是无法预料的

第二种有方法
使得该特定文件会随着系统的重启而销毁
将文件放置到系统/tmp 目录下,/tmp 是一个临时文件系统,当系统重启之后/tmp 目录下的文件就会被销毁,所以该目录下的文件的生命周期便是系统运行周期。

使用文件锁
同样也需要通过一个特定的文件来实现,当程序启动之后,首先打开该文件,调用 open 时一般使用O_WRONLY | O_CREAT 标志,当文件不存在则创建该文件,然后尝试去获取文件锁,若是成功,则将程序的进程号(PID)写入到该文件中,写入后不要关闭文件或解锁(释放文件锁),保证进程一直持有该文件锁;若是程序获取锁失败,代表程序已经被运行、则退出本次启动。

进程间通信简介

进程间通信(interprocess communication,简称 IPC)指两个进程之间的通信。

进程间通信的机制有哪些?

Linux 内核提供了多种 IPC 机制,基本是从 UNIX 系统继承而来
在这里插入图片描述
总结如下:
⚫ UNIX IPC:管道、FIFO、信号;
⚫ System V IPC:信号量、消息队列、共享内存;
⚫ POSIX IPC:信号量、消息队列、共享内存;
⚫ Socket IPC:基于 Socket 进程间通信。

管道和 FIFO

管道是 UNIX 系统上最古老的 IPC 方法。把一个进程连接到另一个进程的数据流称为管道,管道被抽象成一个文件

管道包括三种:
⚫ 普通管道 pipe:通常有两种限制,一是单工,数据只能单向传输;二是只能在父子或者兄弟进程间使用;
⚫ 流管道 s_pipe:==去除了普通管道的第一种限制,为半双工,可以双向传输;==只能在父子或兄弟进程间使用;
⚫ 有名管道 name_pipe(FIFO):去除了普通管道的第二种限制,并且允许在不相关(不是父子或兄弟关系)的进程间进行通讯。

信号

消息队列

消息队列是消息的链表,存放在内核中并由消息队列标识符标识
消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺陷
消息队列包括 POSIX 消息队列和 System V 消息队列。

信号量

信号量是一个计数器
主要用于控制多个进程间或一个进程内的多个线程间对共享资源的访问,
相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源

它常作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源

共享内存

共享内存就是映射一段能被其它进程所访问的内存,这段共享内存由一个进程创建,但其它的多个进程都可以访问,使得多个进程可以访问同一块内存空间。
共享内存是最快的 IPC 方式

它往往与其它通信机制,譬如结合信号量来使用

套接字(Socket)

Socket 是一种是基于网络的 IPC 方法
网络通信

在一个典型的客户端/服务器场景中,应用程序使用 socket 进行通信的方式如下:
⚫ 各个应用程序创建一个 socket。socket 是一个允许通信的“设备”,两个应用程序都需要用到它。
⚫ 服务器将自己的 socket 绑定到一个众所周知的地址(名称)上使得客户端能够定位到它的位置。

线程

本章开始,将学习 Linux应用编程中非常重要的编程技巧—线程(Thread)
与进程类似,线程是允许应用程序并发执行多个任务的一种机制,线程参与系统调度

事实上,系统调度的最小单元是线程、而并非进程

线程概述

线程是参与系统调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。

一个线程指的是进程中一个单一顺序的控制流(或者说是执行路线、执行流)
一个进程中可以创建多个线程

线程是如何创建起来的?
当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread)
main()函数就是主线程的入口函数,main()函数所执行的任务就是主线程需要执行的任务。

只有主线程的进程称为单线程进程。之前所编写的所有应用程序都是单线程程序
其它线程通常由主线程来创建(调用pthread_create 创建一个新的线程),那么创建的新线程就是主线程的子线程。

⚫ 主线程通常会在最后结束运行,执行各种清理工作,譬如回收各个子线程。

线程的特点?
进程不能运行,真正运行的是进程中的线程。
可以认为进程仅仅是一个容器,它包含了线程运行所需的数据结构、环境变量等信息。

同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。
但同一进程中的多个线程有各自的调用栈(call stack,我们称为线程栈),自己的寄存器环境(registercontext)、自己的线程本地存储(thread-local storage)。

线程具有以下一些特点:
⚫ 线程不单独存在、而是包含在进程中;
⚫ 线程是参与系统调度的基本单位;
⚫ 可并发执行。同一进程的多个线程之间可并发执行,在宏观上实现同时运行的效果;
⚫ 共享进程资源。同一进程中的各个线程,可以共享该进程所拥有的资源,这首先表现在

线程与进程?
多进程编程的劣势:
⚫ 进程间切换开销大。进程间切换开销远大于同一进程的多个线程间切换的开销
⚫ 进程间通信较为麻烦。每个进程都在各自的地址空间中、相互独立、隔离

多线程能够弥补上面的问题:
⚫ 同一进程的多个线程间切换开销比较小。
⚫ 同一进程的多个线程间通信容易。它们共享了进程的地址空间,所以它们都是在同一个地址空间中,通信容易。
⚫ 线程创建的速度远大于进程创建的速度。
⚫ 多线程在多核处理器上更有优势!

线程安全问题、信号处理的问题等,编写与调试一个多线程程序比单线程程序困难得多。
多进程编程通常会用在一些大型应用程序项目中,譬如网络服务器应用程序,在中小型应用程序中用的比较少。

并发和并行
对于串行比较容易理解,它指的是一种顺序执行在这里插入图片描述
并行与串行则截然不同,并行指的是可以并排/并列执行多个任务
并行运行并不一定要同时开始运行、同时结束运行,只需满足在某一个时间段上存在多个任务被多个执行单元同时在运行着
在这里插入图片描述
相比于串行和并行,并发强调的是一种时分复用
与串行的区别在于,它不必等待上一个任务完成之后在做下一个任务,可以打断当前执行的任务切换执行下一个任何,这就是时分复用。
在同一个执行单元上,将时间分解成不同的片段(时间片),每个任务执行一段时间,时间一到则切换执行下一个任务,依次这样轮训(交叉/交替执行),这就是并发运行。

在这里插入图片描述⚫ 串行:一件事、一件事接着做
⚫ 并发:交替做不同的事;
⚫ 并行:同时做不同的事。

对于多核处理器系统来说,它拥有多个执行单元,在操作系统中,多个执行单元以并行方式运行多个线程,同时每一个执行单元以并发方式运行系统中的多个线程。

同时运行
因为处理器的运算速度太快了,交替轮训一次所花费的时间在宏观上几乎是可以忽略不计的,所以表示出来的效果就是同时运行着所有线程。

线程 ID

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

pthread_self()来获取自己的线程 ID

可以使用 pthread_equal()函数来检查两个线程 ID 是否相等

线程 ID 在应用程序中非常有用,原因如下:
⚫ 在一些应用程序中,以特定线程的线程 ID 作为动态数据结构的标签。确定随后对该数据结构执行操作的具体线程。

创建线程

主线程可以使用库函数 pthread_create()负责创建一个新的线程,创建出来的新线程被称为主线程的子线程

#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
thread: pthread_t 类型指针,当 pthread_create()成功返回时,新创建的线程的线程 ID 会保存在参数 thread所指向的内存中,后续的线程相关函数会使用该标识来引用此线程
attr:**pthread_attr_t 类型指针,指向 pthread_attr_t 类型的缓冲区,pthread_attr_t 数据类型定义了线程的各种属性如果将参数 attr 设置为 NULL,那么表示将线程的所有属性设置为默认值,以此创建新线程。
start_routine:参数 start_routine 是一个函数指针,指向一个函数,新创建的线程从start_routine()函数开始运行,该函数返回值类型为 void *
arg:传递给 start_routine()函数的参数。一般情况下,需要将 arg 指向一个全局或堆变量.将参数 arg 设置为 NULL,表示不需要传入参数给 start_routine()函数。

注意 pthread_create()在调用失败时通常会返回错误码,它并不像其它库函数或系统调用一样设置 errno,每个线程都提供了全局变量 errno 的副本
线程创建成功,新线程就会加入到系统调度队列中,获取到 CPU 之后就会立马从 start_routine()函数开始运行该线程的任务;调用 pthread_create()函数后

pthread_t tid;
int ret;
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "Error: %s\n", strerror(ret));
exit(-1);
}

在 Linux系统下,确实是使用 unsigned long int 来表示 pthread_t

编译时出现了错误,提示“对‘pthread_create’未定义的引用”,示例代码确实已经包含了<pthread.h>头文件,但为什么会出现这样的报错,仔细看,这个报错是出现在程序代码链接时、而并非是编译过程,所以可知这是链接库的文件,如何解决呢?

gcc -o testApp testApp.c -lpthread

使用-l 选项指定链接库 pthread,原因在于 pthread 不在 gcc 的默认链接库中,所以需要手动指定
在这里插入图片描述

终止线程

参数 retval 的数据类型为 void *,指定了线程的返回值、也就是线程的退出码

可以通过如下方式终止线程的运行:
⚫ 线程的 start 函数执行 return 语句并返回指定值,返回值就是线程的退出码;
⚫ 线程调用 pthread_exit()函数;
⚫ 调用 pthread_cancel()取消线程

调用 pthread_exit()相当于在线程的 start 函数中执行 return 语句,不同之处在于,可在线程 start 函数所调用的任意函数中调用 pthread_exit()来终止线程。如果主线程调用了pthread_exit(),那么主线程也会终止,但其它线程依然正常运行,直到进程中的所有线程终止才会使得进程终止。

回收线程

通过调用 pthread_join()函数来阻塞等待线程的终止.并获取线程的退出码,回收线程资源

#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);	//将目标线程的退出码保存在*retal 所指向的内存中

pthread_join()执行的功能类似于针对进程的 waitpid()调用。还是有区别
⚫ ==线程之间关系是对等的。进程中的任意线程均可调用 pthread_join()函数来等待另一个线程的终止。==进程只能回收子
⚫ 不能以非阻塞的方式调用 pthread_join()

取消线程

在通常情况下,进程中的多个线程会并发执行,每个线程各司其职
取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。

通过调用 pthread_cancel()库函数向一个指定的线程发送取消请求

#include <pthread.h>
int pthread_cancel(pthread_t thread);

但是,线程可以设置自己不被取消或者控制如何被取消。所以pthread_cancel()并不会等待线程终止,仅仅只是提出请求。

取消状态以及类型
线程可以选择不被取消或者控制如何被取消,通过 pthread_setcancelstate()和 pthread_setcanceltype()来设置线程的取消性状态和类型。

#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);

/* 设置为不可被取消 */
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);

参数 state 必须是以下值之一:
⚫ PTHREAD_CANCEL_ENABLE:线程可以取消,这是新创建的线程取消性状态的默认值,所以新建线程以及主线程默认都是可以取消的。
⚫ PTHREAD_CANCEL_DISABLE:线程不可被取消,如果此类线程接收到取消请求,则会将请求挂起,直至线程的取消性状态变为 PTHREAD_CANCEL_ENABLE。

如果线程的取消性状态为 PTHREAD_CANCEL_ENABLE,那么对取消请求的处理则取决于线程的取消性类型。
参数 type 必须是以下值之一:
⚫ PTHREAD_CANCEL_DEFERRED:取消请求到来时,线程还是继续运行,取消请求被挂起,直到线程到达某个取消点(cancellation point,将在 11.6.3 小节介绍)为止,这是所有新建线程包括主线程默认的取消性类型。
⚫ PTHREAD_CANCEL_ASYNCHRONOUS:可能会在任何时间点(也许是立即取消,但不一定)取消线程,这种取消性类型应用场景很少

取消点
那什么是取消点呢?所谓取消点其实就是一系列函数,当执行到这些函数的时候,才会真正响应取消请求
在这里插入图片描述
线程在调用这些函数时,如果收到了取消请求,那么线程便会遭到取消

线程可取消性的检测
pthread_testcancel(),该函数目的很简单,就是产生一个取消点,线程如果已有处于挂起状态的取消请求,那么只要调用该函数,线程就会随之终止。

分离线程

有时,程序员并不关系线程的返回状态,只是希望系统在线程终止时能够自动回收线程资源并将其移除。在这种情况下,可以调用 pthread_detach()将指定线程进行分离

一旦线程处于分离状态,就不能再使用 pthread_join()来获取其终止状态
处于分离状态的线程,当其终止后,能够自动回收线程资源

注册线程清理处理函数

使用 atexit()函数注册进程终止处理函数
一个线程可以注册多个清理函数,这些清理函数记录在栈中,每个线程都可以拥有一个清理函数栈,栈是一种先进后出的数据结构,也就是说它们的执行顺序与注册(添加)顺序相反

线程通过函数 pthread_cleanup_push()和 pthread_cleanup_pop()分别负责向调用线程的清理函数栈中添加和移除清理函数

当线程执行以下动作时,清理函数栈中的清理函数才会被执行:
⚫ 线程调用 pthread_exit()退出时;
⚫ 线程响应取消请求时;
==⚫ 用非 0 参数调用 pthread_cleanup_pop() ==

static void cleanup(void *arg)
{
printf("cleanup: %s\n", (char *)arg);
}

static void *new_thread_start(void *arg)
{
printf("新线程--start run\n");
pthread_cleanup_push(cleanup, "第 1 次调用");
pthread_cleanup_push(cleanup, "第 2 次调用");
pthread_cleanup_push(cleanup, "第 3 次调用");
sleep(2);
pthread_exit((void *)0);
//线程终止
/* 为了与 pthread_cleanup_push 配对,不添加程序编译会通不过 */
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
}

线程属性

在 Linux 下,使用pthread_attr_t 数据类型定义线程的所有属性

当定义 pthread_attr_t 对象之后 , 需 要 使 用 pthread_attr_init() 函 数 对 该 对 象 进 行 初 始 化操作 ,当对象不再使用时 , 需要使用pthread_attr_destroy()函数将其销毁

#include <pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);

本小节并不会一一点出,可能比较关注属性包括:线程栈的位置和大小、线程调度策略和优先级,以及线程的分离状态属性等

线程栈属性
每个线程都有自己的栈空间,pthread_attr_t 数据结构中定义了栈的起始地址以及栈大小
pthread_attr_getstack()可以获取这些信息,函数 pthread_attr_setstack()对栈起始地址和栈大小进行设置

分离状态属性
如果我们在创建线程时就确定要将该线程分离,
可以修改 pthread_attr_t 结构中的 detachstate 线程属性
调用函数 pthread_attr_setdetachstate()设置 detachstate 线程属性,调用pthread_attr_getdetachstate()获取 detachstate 线程属性

#include <pthread.h>
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);

detachstate 取值如下:
⚫ PTHREAD_CREATE_DETACHED:新建线程一开始运行便处于分离状态,以分离状态启动线程,无法被其它线程调用 pthread_join()回收,线程结束后由操作系统收回其所占用的资源;
⚫ PTHREAD_CREATE_JOINABLE:这是 detachstate 线程属性的默认值,正常启动线程,可以被其它线程获取终止状态信息。

线程同步

多个线程并发访问共享数据所导致的数据不一致的问题。

为什么需要线程同步?

**线程同步是为了对共享资源的访问进行保护
保护的目的是为了解决数据一致性的问题。**但是,当一个线程可以修改的变量,其它的线程也可以读取或者修改的时候,这个时候就存在数据一致性的问题,需要对这些线程进行同步操作,确保它们在访问变量的存储内容时不会访问到无效的值。
出现数据一致性问题其本质在于进程中的多个线程对共享资源的并发访问(同时访问)
要防止并发访问共享资源,那么就需要对共享资源的访问进行保护,防止出现并发访问共享资源。

当一个线程修改变量时,其它的线程在读取这个变量时可能会看到不一致的值
在这里插入图片描述
如何解决对共享资源的并发访问出现数据不一致的问题?
线程的主要优势在于,资源的共享性,譬如通过全局变量来实现信息共享。不过这种便捷的共享是有代价的,必须确保多个线程不会同时修改同一变量、或者某一线程不会读取正由其它线程修改的变量

Linux 系统提供了多种用于实现线程同步的机制,常见的方法有:互斥锁、条件变量、自旋锁以及读写锁等

互斥锁

互斥锁(mutex)又叫互斥量,从本质上说是一把锁
在访问共享资源之前对互斥锁进行上锁,在访问完成后释放互斥锁(解锁)
任何其它试图再次对互斥锁进行加锁的线程都会被阻塞,直到当前线程释放互斥锁。
如果释放互斥锁时有一个以上的线程阻塞,那么这些阻塞的线程会被唤醒。它们都会尝试对互斥锁进行加锁,当有一个线程成功对互斥锁上锁之后,其它线程就不能再次上锁了

在我们的程序设计当中,只有将所有线程访问共享资源都设计成相同的数据访问规则,互斥锁才能正常工作。

互斥锁使用 pthread_mutex_t 数据类型表示,在使用互斥锁之前,必须首先对它进行初始化操作
互斥锁初始化
1、使用 PTHREAD_MUTEX_INITIALIZER 宏初始化互斥锁

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

PTHREAD_MUTEX_INITIALIZER 宏已经携带了互斥锁的默认属性。

2、使用 pthread_mutex_init()函数初始化互斥锁

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);

pthread_mutex_init(&mutex, NULL);

互斥锁加锁和解锁
调用函数 pthread_mutex_lock()可以对互斥锁加锁、获取互斥锁,
而调用函数 pthread_mutex_unlock()可以对互斥锁解锁、释放互斥锁。

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

如果互斥锁处于未锁定状态,则此次调用会上锁成功
如果互斥锁此时已经被其它线程锁定了,那么调用 pthread_mutex_lock()会一直阻塞,直到该互斥锁被解锁,然后唤醒加锁

调用 pthread_mutex_unlock()函数将已经处于锁定状态的互斥锁进行解锁。以下行为均属错误:
⚫ 对处于未锁定状态的互斥锁进行解锁操作;
⚫ 解锁由其它线程锁定的互斥锁。

当互斥锁已经被其它线程锁住时,调用 pthread_mutex_lock()函数会被阻塞,直到互斥锁解锁
不希望被阻塞,可以使用 pthread_mutex_trylock()函数
互斥锁已经被其它线程锁住,调用 pthread_mutex_trylock()加锁失败,但不会阻塞,而是返回错误码 EBUSY

销毁互斥锁
通过调用 pthread_mutex_destroy()函数来销毁互斥锁
⚫ 不能销毁还没有解锁的互斥锁,否则将会出现错误;
⚫ 没有初始化的互斥锁也不能销毁。

互斥锁死锁
如果一个线程试图对同一个互斥锁加锁两次。线程会陷入死锁状态,一直被阻塞永远出不来;这就是出现死锁的一种情况
当超过一个线程对同一组互斥锁(两个或两个以上的互斥锁)进行加锁时,就有可能发生死锁

当超过一个线程对同一组互斥锁(两个或两个以上的互斥锁)进行加锁时,就有可能发生死锁

互斥锁的属性
调用 pthread_mutex_init()函数初始化互斥锁时可以设置互斥锁的属性
参数 attr 指向一个 pthread_mutexattr_t 类型对象,该对象对互斥锁的属性进行定义

互斥锁的类型属性控制着互斥锁的锁定特性,一共有 4 中类型:
⚫ PTHREAD_MUTEX_NORMAL:一种标准的互斥锁类型,不做任何的错误检查或死锁检测。
⚫ PTHREAD_MUTEX_ERRORCHECK:此类互斥锁会提供错误检查
⚫ PTHREAD_MUTEX_RECURSIVE:此类互斥锁允许同一线程在互斥锁解锁之前对该互斥锁进行多次加锁,然后维护互斥锁加锁的次数,把这种互斥锁称为递归互斥锁

条件变量

本小节讨论第二种线程同步的方法—条件变量。
条件变量用于自动阻塞线程,直到某个特定事件发生或某个条件满足为止
通常情况下,条件变量是和互斥锁一起搭配使用的
⚫ 一个线程等待某个条件满足而被阻塞;
⚫ 另一个线程中,条件满足时发出“信号”

Tips:这里提到的信号并不是第八章内容所指的信号,需要区分开来!

条件变量初始化
初始化方式同样也有两种:使用宏 PTHREAD_COND_INITIALIZER 或者使用函数 pthread_cond_init()

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);

通知和等待条件变量
条件变量的主要操作便是发送信号(signal)和等待
发送信号操作即是通知一个或多个处于等待状态的线程

函数 pthread_cond_signal()和 pthread_cond_broadcast()均可向指定的条件变量发送信号
调用 pthread_cond_wait()函数让线程阻塞,直到收到条件变量的通知

pthread_cond_signal()和 pthread_cond_broadcast()的区别在于:二者对阻塞于 pthread_cond_wait()的多个线程对应的处理方式不同
pthread_cond_signal()函数至少能唤醒一个线程
而 pthread_cond_broadcast()函数则能唤醒所有线程

调用pthread_cond_wait()函数时,调用者把互斥锁传递给函数,函数会自动把调用线程放到等待条件的线程列表上,然后将互斥锁解锁;当 pthread_cond_wait()被唤醒返回时,会再次锁住互斥锁。

条件变量的属性
参数 attr 指向一个 pthread_condattr_t 类型对象,该对象对条件变量的属性进行定义,
条件变量包括两个属性:进程共享属性和时钟属性

自旋锁

从实现方式上来说,互斥锁是基于自旋锁来实现的
如果在获取自旋锁时,自旋锁已经处于锁定状态了,那么获取锁操作将会在原地“自旋”,直到该自旋锁的持有者释放了锁
“自旋”其实就是调用者一直在循环查看该自旋锁的持有者是否已经释放了锁

试图对同一自旋锁加锁两次必然会导致死锁,而试图对同一互斥锁加锁两次不一定会导致死锁,原因在于互斥锁有不同的类型,当设置为 PTHREAD_MUTEX_ERRORCHECK 类型时,会进行错误检查

综上所述,再来总结下自旋锁与互斥锁之间的区别:
⚫ 开销上的区别:获取不到互斥锁会陷入阻塞状态(休眠).休眠与唤醒开销是很大的,所以互斥锁的开销要远高于自旋锁
但如果长时间的“自旋”等待,会使得 CPU 使用效率降低
⚫ 使用场景的区别:自旋锁在用户态应用程序中使用的比较少,通常在内核代码中使用比较多
自旋锁可以在中断服务函数中使用,而互斥锁则不行,在执行中断服务函数时要求不能休眠、不能被抢占(内核中使用自旋锁会自动禁止抢占)

自旋锁初始化
自旋锁使用 pthread_spinlock_t 数据类型表示,当定义自旋锁后,需要使用 pthread_spin_init()函数对其进行初始化,当不再使用自旋锁时,调用 pthread_spin_destroy()函数将其销毁

#include <pthread.h>
int pthread_spin_destroy(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

参数 pshared 表示自旋锁的进程共享属性值如下:
⚫ PTHREAD_PROCESS_SHARED:共享自旋锁。该自旋锁可以在多个进程中的线程之间共享;
⚫ PTHREAD_PROCESS_PRIVATE:私有自旋锁。只有本进程内的线程才能够使用该自旋锁。

自旋锁加锁和解锁

#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);

读写锁

互斥锁或自旋锁要么是加锁状态、要么是不加锁状态,而且一次只有一个线程可以对其加锁
读写锁有3 种状态:读模式下的加锁状态(以下简称 读加锁状态)、写模式下的加锁状态(以下简称 写加锁状态)和不加锁状态(见)
在这里插入图片描述
读写锁有如下两个规则:
⚫ 当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行加锁操作(不管是以读模式加锁还是以写模式加锁)的线程都会被阻塞。
⚫ 当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功;但是任何以写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止。

所以,读写锁非常适合于对共享数据读的次数远大于写的次数的情况
当读写锁处于写模式加锁状态时,它所保护的数据可以被安全的修改,因为一次只有一个线程可以在写模式下拥有这个锁
当读写锁处于读模式加锁状态时,它所保护的数据就可以被多个获取读模式锁的线程读取

读写锁也叫做共享互斥锁。当读写锁是读模式锁住时,就可以说成是共享模式锁住。当它是写模式锁住时,就可以说成是互斥模式锁住。

读写锁初始化

pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);

pthread_rwlockattr_t 数据类型定义了读写锁的属性
当读写锁不再使用时,需要调用 pthread_rwlock_destroy()函数将其销毁。

读写锁上锁和解锁

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

不管是以何种方式锁住读写锁,均可以调用 pthread_rwlock_unlock()函数解锁

如果线程不希望被阻塞,可以调用 pthread_rwlock_tryrdlock()和 pthread_rwlock_trywrlock()来尝试加锁

读写锁的属性
读写锁的属性使用 pthread_rwlockattr_t 数据类型来表示

int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);

参数 pshared 可取值如下:
⚫ PTHREAD_PROCESS_SHARED:共享读写锁。该读写锁可以在多个进程中的线程之间共享;
⚫ PTHREAD_PROCESS_PRIVATE:私有读写锁。只有本进程内的线程才能够使用该读写锁,这是读写锁共享属性的默认值。

总结

线程同步的方法其实还有很多,譬如信号量、屏障等等
在实际应用开发当中,用的最多的还是互斥锁和条件变量

线程安全

当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题

线程栈

进程中创建的每个线程都有自己的栈地址空间
在创建一个新的线程时,可以配置线程栈的大小以及起始地址,当然在大部分情况下,保持默认即可!
每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰

可重入函数

一个单线程进程(包含信号处理)中形成了两条(即主程序和信号处理函数)独立的执行流

如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。

Tips:上面所说的同时指的是宏观上同时调用

重入指的是同一个函数被不同执行流调用
在这里插入图片描述
以上举例说明了函数被多个执行流同时调用的两种情况:
⚫ 在一个含有信号处理的程序当中,主程序正执行函数 func(),此时进程接收到信号,主程序被打断,跳转到信号处理函数中执行,信号处理函数中也调用了 func()。
⚫ 在多线程环境下,多个线程并发调用同一个函数。

在多线程环境以及信号处理有关应用程序中,需要注意不可重入函数的问题

可重入函数的分类
笔者认为可重入函数可以分为两类:
⚫ 绝对的可重入函数:所谓绝对,指的是该函数不管如何调用,都刚断言它是可重入的,都能得到预期的结果。
⚫ 带条件的可重入函数:指的是在满足某个/某些条件的情况下,可以断言该函数是可重入的,不管怎么调用都能得到预期的结果。

绝对可重入函数
总结下绝对可重入函数的特点:
⚫ 函数内所使用到的变量均为局部变量,换句话说,该函数内的操作的内存地址均为本地栈地址;
⚫ 函数参数和返回值均是值类型;
⚫ 函数内调用的其它函数也均是绝对可重入函数。

带条件的可重入函数
带条件的可重入函数通常需要满足一定的条件时才是可重入函数
虽然引入的是全局变量或者指针,但是只读不写就可重入

线程安全函数
了解了可重入函数之后,再来看看线程安全函数。
在这里插入图片描述
判断一个函数是否为线程安全函数的方法是,该函数被多个线程同时调用是否总能产生正确的结果
读一个函数是否为可重入函数的方法是,从语言语法角度分析,该函数被多个执行流同时调用是否总能产生正确的结果

POSIX.1-2001 和 POSIX.1-2008 标准中规定的所有函数都必须是线程安全函数,但以下函数除外:
在这里插入图片描述对于一个中大型的多线程应用程序项目来说,能够保证整个程序的安全性,这是非常重要的

一次性初始化

在多线程编程环境下,有些代码段只需要执行一次,譬如一些初始化相关的代码段,通常比较容易想到的就是将其放在 main()主函数进行初始化,这样也就是意味着该段代码只在主线程中被调用,只执行过一次

在多线程编程环境下,尽管 pthread_once()调用会出现在多个线程中,但该函数会保证 init_routine()函数仅执行一次

#include <pthread.h>
pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));

线程特有数据

线程特有数据也称为线程私有数据
就是为每个调用线程分别维护一份变量的副本(copy),每个线程通过特有数据键(key)访问时,这个特有数据键都会获取到本线程绑定的变量副本。避免变量成为多个线程间的共享数据。

C 库中有很多函数都是非线程安全函数,非线程安全函数在多线程环境下,被多个线程同时调用时将会发生意想不到的结果,得不到预期的结果。多次调用这些函数返回的字符串其实指向的是同一个缓冲区

线程特有数据的核心思想其实非常简单,就是为每一个调用线程。分配属于该线程的私有数据区

步骤
1)在为线程分配私有数据区之前,需要调用 pthread_key_create()函数创建一个特有数据键(key),并且只需要在首个调用的线程中创建一次即可所以通常会使用到上小节所学习的 pthread_once()函数。

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
key:调用该函数会创建一个特有数据键,并通过参数 key 所指向的缓冲区返回给调用者
destructor:参数 destructor 是一个函数指针,指向一个自定义的函数

2)调用 pthread_key_create()函数创建特有数据键(key)后通常需要为调用线程分配私有数据缓冲区。譬如通过 malloc() (或类似函数)申请堆内存
为线程分配私有数据缓冲区之后,通常需要调用 pthread_setspecific()函数。首先保存指向线程私有数据缓冲区的指针,并将其与特有数据键以及当前调用线程关联起来

int pthread_setspecific(pthread_key_t key, const void *value);
value:参数 value 是一个 void 类型的指针,指向由调用者分配的一块内存,

调用 pthread_setspecific()函数将线程私有数据缓冲区与调用线程以及特有数据键关联之后,便可以使用pthread_getspecific()函数来获取调用线程的私有数据区了

pthread_key_delete()函数

线程局部存储

程序中定义的全局变量是进程中所有线程共享的,所有线程都可以访问这些全局变量
线程局部存储在定义全局或静态变量时,使用__thread 修饰符修饰变量,此时,每个线程都会拥有一份对该变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储

线程局部存储的主要优点在于,比线程特有数据的使用要简单。要创建线程局部变量,只需简单地在全局或静态变量的声明中包含__thread 修饰符即可!

程的副本,从而避免了全局变量成为多个线程的共享数据。
关于线程局部变量的声明和使用,需要注意以下几点:
⚫ 如果变量声明中使用了关键字 static 或 extern,那么关键字__thread 必须紧随其后。
⚫ 与一般的全局或静态变量申明一眼,线程局部变量在申明时可设置一个初始值。
⚫ 可以使用 C 语言取值操作符(&)来获取线程局部变量的地址。
Tips:线程局部存储需要内核、Pthreads 以及 GCC 编译器的支持。

线程与信号

⑴、信号如何映射到线程
信号模型在一些方面是属于进程层面(由进程中的所有线程线程共享)的
⚫ 信号的系统默认行为是属于进程层面。信号的默认操作通常是停止或终止进程。
⚫ 信号处理函数属于进程层面。进程中的所有线程共享程序中所注册的信号处理函数;
⚫ 信号的发送既可针对整个进程,也可针对某个特定的线程。在满足以下三个条件中的任意一个时,信号的发送针对的是某个线程:
➢ 产生了硬件异常相关信号,譬如 SIGBUS、SIGFPE、SIGILL 和 SIGSEGV 信号;
这些硬件异常信号是由某个线程所引起;那么在这种情况下,系统会将信号发送给该线程。
➢ 当线程试图对已断开的管道进行写操作时所产生的 SIGPIPE 信号;
➢ 由函数 pthread_kill()或 pthread_sigqueue()所发出的信号
除了以上提到的三种情况之外,其它机制产生的信号均属于进程层面
⚫ 当一个多线程进程接收到一个信号时,且该信号绑定了信号处理函数时,内核会任选一个线程来接收这个信号,
⚫ 信号掩码其实是属于线程层面的,也就是说信号掩码是针对每个线程而言
线程可以调用 pthread_sigmask()函数来设置它们各自的信号掩码
⚫ 针对整个进程所挂起的信号,以及针对每个线程所挂起的信号,内核都会分别进行维护、记录

⑵、线程的信号掩码
对于一个单线程程序来说,使用 sigprocmask()函数设置进程的信号掩码
在多线程环境下,使用pthread_sigmask()函数来设置各个线程的信号掩码

⑶、向线程发送信号

#include <signal.h>
int pthread_kill(pthread_t thread, int sig);

除了 pthread_kill()函数外,还可以调用 pthread_sigqueue()函数

#include <signal.h>
#include <pthread.h>
int pthread_sigqueue(pthread_t thread, int sig, const union sigval value);

⑷、异步信号安全函数
应用程序中涉及信号处理函数时必须要非常小心,因为信号处理函数可能会在程序执行的任意时间点被调用,从而打断主程序

异步信号安全函数指的是可以在信号处理函数中可以被安全调用的线程安全函数,所以它比线程安全函数的要求更为严格!

笔者认为主要原因在以下两个方面:
⚫ 信号是异步的,信号可能会在任何时间点中断主程序的运行,跳转到信号处理函数处执行,从而形成一个新的执行流(信号处理函数执行流)。
⚫ 信号处理函数执行流与线程执行流存在一些区别,信号处理函数所产生的执行流是由执行信号处理函数的线程所触发的,它俩是在同一个线程中,属于同一个线程执行流。

在异步信号安全函数、可重入函数以及线程安全函数三者中,可重入函数的要求是最严格的
如果想将其实现为异步信号安全函数,可以在获取锁之前通过设置信号掩码,在锁期间禁止接收该信号,也就是说将函数实现为不可被信号中断

所以对于一个安全的信号处理函数来说,需要做到以下几点:
⚫ 首先确保信号处理函数本身的代码是可重入的,且只能调用异步信号安全函数;
当主程序执行不安全函数或是去操作信号处理函数也可能会更新的全局数据结构时,要阻塞信号的传递。

⑸、多线程环境下信号的处理

高级 I/O

本章再次回到文件 I/O 相关话题的讨论,将会介绍文件 I/O 当中的一些高级用法
主要包括:非阻塞 I/O、I/O 多路复用、异步 I/O、存储映射 I/O 以及文件锁

非阻塞 I/O

阻塞其实就是进入了休眠状态,交出了 CPU 控制权
wait()、pause()、sleep()等函数都会进入阻塞

这里举个例子,譬如对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒

阻塞 I/O 与非阻塞 I/O 读文件
在调用 open()函数打开文件时,为参数 flags 指定 O_NONBLOCK 标志,open()调用成功后,后续的 I/O 操作将以非阻式方式进行;

对于普通文件来说,指定与未指定 O_NONBLOCK 标志对其是没有影响,普通文件的读写操作是不会阻塞的

阻塞 I/O 的优点与缺点
当对文件进行读取操作时,如果文件当前无数据可读,那么阻塞式 I/O 会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞
而对于非阻塞 I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮训等待,直到数据可读,要么直接放弃!

其 CPU 占用率几乎达到了 100%,在一个系统当中,一个进程的 CPU 占用率这么高是一件非常危险的事情
阻塞式方式,其 CPU 占用率几乎为 0

使用非阻塞 I/O 实现并发读取

这就是阻塞式 I/O 的一个困境,无法实现并发读取(同时读取),原因在于读取

解决办法:

1)多线程

2)非阻塞解决

因为标准输入文件描述符(键盘)是从其父进程进程而来,并不是在我们的程 序中调用 open()打开得到的,那如何将标准输入设置为非阻塞 I/O

int flag;
flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_NONBLOCK; //将 O_NONBLOCK 标志添加到 flag
fcntl(0, F_SETFL, flag); //重新设置 flag

但由于程序当中使用轮训方式,故而会使得该程序的 CPU 占用率特别高,终归还是不太安全,会对整个系统产生很大的副作用

I/O 多路复用

何为 I/O 多路复用

I/O 多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作

I/O 多路复用一般用于并发式的非阻塞 I/O。譬如程序中既要读取鼠标、又要读取键盘,多路读取。

I/O 多路复用存在一个非常明显的特征:外部阻塞式,内部监视多路 I/O。

**select()**函数介绍

调用 select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select()函数的第一个参数 nfds 通常表示最大文件描述符编号值加 1

⚫ readfds 是用来检测读是否就绪(是否可读)的文件描述符集合;

⚫ writefds 是用来检测写是否就绪(是否可写)的文件描述符集合;

⚫ exceptfds 是用来检测异常情况是否发生的文件描述符集合。

Linux 提供了四个宏用于对 fd_set 类型对象进行操作

FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()

如果对 readfds、writefds 以及 exceptfds 中的某些事件不感兴趣,可将其设置为 NULL,这表示对相应条件不关心。如果这三个参数都设置为 NULL,则可以将 select()当做为一个类似于 sleep()休眠

select()函数的最后一个参数 timeout 可用于设定 select()阻塞的时间上限

select()函数将阻塞知道有以下事情发生:
⚫ readfds、writefds 或 exceptfds 指定的文件描述符中至少有一个称为就绪态;
⚫ 该调用被信号处理函数中断;
⚫ 参数 timeout 中指定的时间上限已经超时。

FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()

文件描述符集合的所有操作都可以通过这四个宏来完成

#include <sys/select.h>
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

这些宏按照如下方式工作:

⚫ FD_ZERO()将参数 set 所指向的集合初始化为空;

⚫ FD_SET()将文件描述符 fd 添加到参数 set 所指向的集合中;

⚫ FD_CLR()将文件描述符 fd 从参数 set 所指向的集合中移除

⚫ 如果文件描述符 fd 是参数 set 所指向的集合中的成员,则 FD_ISSET()返回 true,否则返回 false。

文件描述符集合有一个最大容量限制,有常量 FD_SETSIZE 来决定,在 Linux 系统下,该常量的值为1024。

在定义一个文件描述符集合之后,必须用 FD_ZERO()宏将其进行初始化操作

fd_set fset; //定义文件描述符集合
FD_ZERO(&fset); //将集合初始化为空
FD_SET(3, &fset); //向集合中添加文件描述符 3
FD_SET(4, &fset); //向集合中添加文件描述符 4
FD_SET(5, &fset); //向集合中添加文件描述符 5

譬如在调用 select()函数之前,readfds 所指向的集合中包含了 3、4、5 这三个文件描述符,当调用 select()函数之后,假设 select()返回时,只有文件描述符 4 已经处于就绪态了,那么此时 readfds 指向的集合中就只包含了文件描述符 4。

所以由此可知,如果要在循环中重复调用 select(),我们必须保证每次都要重新初始化并设置 readfds、writefds、exceptfds 这些集合。

select()函数有三种可能的返回值,会返回如下三种情况中的一种:

⚫ 返回-1 表示有错误发生,并且会设置 errno。

⚫ 返回 0 表示在任何文件描述符成为就绪态之前 select()调用已经超时

⚫ 返回一个正整数表示有一个或多个文件描述符已达到就绪态

**poll()**函数介绍

在 select()函数中,我们提供三个 fd_set 集合,在每个集合中添加我们关心的文件描述符;

而在 poll()函数中,则需要构造一个 struct pollfd 类型的数组,每个数组元素指定一个文件描述符以及我们对该文件描述符所关心的条件(数据可读、可写或异常情况)

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

fds**:**指向一个 struct pollfd 类型的数组,数组中的每个元素都会指定一个文件描述符以及我们对该文件描述符所关心的条件

nfds**:**参数 nfds 指定了 fds 数组中的元素个数

timeout**:**

⚫ 如果 timeout 等于-1,则 poll()会一直阻塞

⚫ 如果 timeout 等于 0,poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态。

⚫ 如果 timeout 大于 0,则表示设置 poll()函数阻塞时间的上限值

struct pollfd 结构体

struct pollfd {
 int fd; /* file descriptor */
 short events; /* requested events */
 short revents; /* returned events */
};

调用者初始化 events 来指定需要为文件描述符 fd 做检查的事件。
当 poll()函数返回时,revents 变量由 poll()函数内部进行设置,用于说明文件描述符 fd 发生了哪些事件
在这里插入图片描述

总结
在使用 select()或 poll()时需要注意一个问题,当监测到某一个或多个文件描述符成为就绪态(可以读或写)时,需要执行相应的 I/O 操作,以清除该状态,否则该状态将会一直存在
如果不清除就绪态,那么该状态将会一直存在,那么下一次调用 select()时,文件描述符已经处于就绪态了,将直接返回。

异步 IO

在异步 I/O 中,当文件描述符上可以执行 I/O 操作时,进程可以请求内核为自己发送一个信号。之后进程就可以执行任何其它的任务直到文件描述符可以执行 I/O 操作为止,此时内核会发送信号给进程

要使用异步 I/O,程序需要按照如下步骤来执行:
⚫ 通过指定 O_NONBLOCK 标志使能非阻塞 I/O。
⚫ 通过指定 O_ASYNC 标志使能异步 I/O。
⚫ 设置异步 I/O 事件的接收进程。也就是当文件描述符上可执行 I/O 操作时会发送信号通知该进程,通常将调用进程设置为异步 I/O 事件的接收进程。
⚫ 为内核发送的通知信号注册一个信号处理函数。默认情况下,异步 I/O 的通知信号是 SIGIO
⚫ 以上步骤完成之后,进程就可以执行其它任务了.,当进程接收到信号时,会执行预先注册好的信号处理函数,我们就可以在信号处理函数中进行 I/O 操作。

设置异步 I/O 事件的接收进程
O_ASYNC 标志可用于使能文件描述符的异步 I/O 事件,当文件描述符可执行 I/O 操作时,内核会向异步 I/O 事件的接收进程发送 SIGIO 信号(默认情况下)

int flag;
flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_ASYNC; //将 O_ASYNC 标志添加到 flag
fcntl(fd, F_SETFL, flag); //重新设置 flag

注册 SIGIO 信号的处理函数
通过 signal()或 sigaction()函数为 SIGIO 信号注册一个信号处理函数

优化异步 I/O

在一个需要同时检查大量文件描述符(譬如数千个)的应用程序中,例如某种类型的网络服务端程序,与 select()和 poll()相比,异步 I/O 能够提供显著的性能优势。

对于 select()或 poll()函数来说,内部实现原理其实是通过轮训的方式来检查多个文件描述符是否可执行 I/O 操作,所以,当需要检查的文件描述符数量较多时,随之也将会消耗大量的 CPU 资源

Tips:当需要检查大量文件描述符时,可以使用 epoll 解决 select()或 poll()性能低的问题

对上一小节所讲述的异步 I/O 进行优化
⚫ 默认的异步 I/O 通知信号 SIGIO 是非排队信,标准信号
⚫ 无法得知文件描述符发生了什么事件

使用实时信号替换默认信号 SIGIO
SIGIO 作为异步 I/O 通知的默认信号,是一个非实时信号,我们可以设置不使用默认信号,指定一个实时信号作为异步 I/O 通知信号
是使用 fcntl()函数进行设置,调用函数时将操作命令cmd 参数设置为 F_SETSIG

fcntl(fd, F_SETSIG, SIGRTMIN);

使用 sigaction()函数注册信号处理函数
在应用程序当中需要为实时信号注册信号处理函数,使用 sigaction 函数进行注册,并为 sa_flags 参数指定 SA_SIGINFO

存储映射 I/O

存储映射 I/O(memory-mapped I/O)是一种基于内存区域的高级 I/O 操作,它能将一个文件映射到进程地址空间中的一块内存区域中
当从这段内存中读写数据时,就相当于读写文件中的数据。这样就可以在不使用基本 I/O 操作函数 read()和 write()的情况下执行 I/O 操作

mmap()和 munmap()函数
为了实现存储映射 I/O 这一功能,我们需要告诉内核将一个给定的文件映射到进程地址空间中的一块内存区域中,这由系统调用 mmap()来实现

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

prot:参数 prot 指定了映射区的保护要求,可取值如下:
⚫ PROT_EXEC:映射区可执行;
⚫ PROT_READ:映射区可读;
⚫ PROT_WRITE:映射区可写;
⚫ PROT_NONE:映射区不可访问。

flags:参数 flags 可影响映射区的多种属性,参数 flags 必须要指定以下两种标志之一:
⚫ MAP_SHARED:数据会写入到文件中并且允许其它进程共享。
⚫ MAP_PRIVATE:会创建映射文件的一个私人副本
在这里插入图片描述
对于 mmap()函数,参数 addr 和 offset 在不为 NULL 和 0 的情况下,addr 和 offset 的值通常被要求是系统页大小的整数倍,可通过 sysconf()函数获取页大小

sysconf(_SC_PAGE_SIZE)sysconf(_SC_PAGESIZE)

munmap()解除映射
需要注意的是,当进程终止时也会自动解除映射

mprotect()函数
使用系统调用 mprotect()可以更改一个现有映射区的保护要求

msync()函数
read()和 write()系统调用在操作磁盘文件时仅仅在用户空间缓冲区和内核缓冲区之间复制数据。在后续的某个时刻,内核会将其缓冲区中的数据写入(刷新至)磁盘中

所以由此可知,调用 write()写入到磁盘文件中的数据并不会立马写入磁盘
所以就会出现 write()操作与磁盘操作并不同步

对于存储 I/O 来说亦是如此,写入到文件映射区中的数据也不会立马刷新至磁盘设备中
我们可以调用 msync()函数将映射区中的数据刷写、更新至磁盘文件中(同步操作)
系统调用 msync()类似于 fsync()函数,不过 msync()作用于映射区

文件锁

前面学习过互斥锁、自旋锁以及读写锁,文件锁与这些锁一样,都是内核提供的锁机制

文件锁可以分为建议性锁和强制性锁两种:
⚫ 建议性锁
但是如果你的程序不管三七二十一,在没有对文件上锁的情况下直接访问文件,也是可以访问的,并非无法访问文件
如果要使得建议性锁起作用,那么大家就要遵守协议,访问文件之前先对文件上锁。
⚫ 强制性锁:
强制性锁会让内核检查每一个 I/O 操作(譬如 read()、write()),验证调用进程是否是该文件锁的拥有者

flock()函数加锁
系统调用int flock(int fd, int operation);,使用该函数可以对文件加锁或者解锁,但是 flock()函数只能产生建议性锁

operation:参数 operation 指定了操作方式,可以设置为以下值的其中一个:
⚫ LOCK_SH:在 fd 引用的文件上放置一把共享锁。
⚫ LOCK_EX:在 fd 引用的文件上放置一把排它锁(或叫互斥锁)
⚫ LOCK_UN:解除文件锁定状态
⚫ LOCK_NB:表示以非阻塞方式获取锁。默认情况下,调用 flock()无法获取到文件锁时会阻塞

关于 flock()的几条规则
⚫ 同一进程对文件多次加锁不会导致死锁。新加的锁会替换旧的锁
⚫ 文件关闭的时候,会自动解锁。
⚫ 一个进程不可以对另一个进程持有的文件锁进行解锁。
⚫ 由 fork()创建的子进程不会继承父进程所创建的锁。

除此之外,当一个文件描述符被复制时(譬如使用 dup()、dup2()或 fcntl()F_DUPFD 操作),这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁

flock(fd, LOCK_EX); //加锁
new_fd = dup(fd);
flock(new_fd, LOCK_UN); //解锁

fcntl()函数加锁
⚫ flock()仅支持对整个文件进行加锁/解锁;而 fcntl()可以对文件的某个区域(某部分内容)进行加锁解锁,可以精确到某一个字节数据。
⚫ flock()仅支持建议性锁类型;而 fcntl()可支持建议性锁和强制性锁两种类型。

int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );

struct flock {
 ...
 short l_type; /* Type of lock: F_RDLCK,F_WRLCK, F_UNLCK */
 short l_whence; /* How to interpret l_start: SEEK_SET, SEEK_CUR, SEEK_END */
 off_t l_start; /* Starting offset for lock */
 off_t l_len; /* Number of bytes to lock */
 pid_t l_pid; /* PID of process blocking our lock(set by F_GETLK and F_OFD_GETLK) */
 ...
};

对 struct flock 结构体说明如下:
⚫ l_type:所希望的锁类型,可以设置为 F_RDLCK、F_WRLCK 和 F_UNLCK 三种类型之一,F_RDLCK表示共享性质的读锁,F_WRLCK 表示独占性质的写锁,F_UNLCK 表示解锁一个区域
⚫ l_whence 和 l_start:这两个变量用于指定要加锁或解锁区域的起始字节偏移量
⚫ l_len:需要加锁或解锁区域的字节长度
⚫ l_pid:一个 pid,指向一个进程,表示该进程持有的锁能阻塞当前进程,当 cmd=F_GETLK 时有效。

几项规则:
⚫ 锁区域可以在当前文件末尾处开始或者越过末尾处开始,但是不能在文件起始位置之前开始。
⚫ 若参数 l_len 设置为 0,表示将锁区域扩大到最大范围。而且是动态的
⚫ 如果我们需要对整个文件加锁,可以将 l_whence 和 l_start 设置为指向文件的起始位置,并且指定参数 l_len 等于 0。

我们来看看与文件锁相关的三个 cmd 它们的作用
⚫ F_GETLK:这种用法一般用于测试
⚫ F_SETLK:对文件添加由 flockptr 指向的 struct flock 对象所描述的锁
⚫ F_SETLKW:此命令是 F_SETLK 的阻塞版本

lockf()函数加锁
其内部是基于 fcntl()来实现的,所以 lockf()是对 fcntl 锁的一种封装

小结

⚫ 非阻塞 I/O:进程向文件发起 I/O 操作,使其不会被阻塞。
⚫ I/O 多路复用:select()和 poll()函数。
⚫ 异步 I/O:当文件描述符上可以执行 I/O 操作时,内核会向进程发送信号通知它。
存储映射 I/O:mmap()函数。
⚫ 文件锁:flock()、fcntl()以及 lockf()函数。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐