最全Linux应用开发解析(持续更新)

一、文件IO

1.1 文件描述符

在 Linux 操作系统中的一切都被抽象成了文件,那么一个打开的文件是如何与应用程序进行对应呢?

解决方案是使用文件描述符(file descriptor,简称fd),当在进程中打开一个现有文件或者创建一个新文件时,内核向该进程返回一个文件描述符,用于对应这个打开/新建的文件。这些文件描述符都存储在内核为每个进程维护的一个文件描述符表中

Linux 系统中一切皆文件,系统中一切都被抽象成了文件。对这些文件的读写都需要通过文件描述符来完成。
准 C 库的文件 IO 函数使用的文件指针 FILE* 在 Linux 中也需要通过文件描述符的辅助才能完成读写操作。
FILE 其实是一个结构体,其内部有一个成员就是文件描述符。

在这里插入图片描述
启动一个进程就会得到一个对应的虚拟地址空间,这个虚拟地址空间分为两大部分,在内核区有专门用于进程管理的模块。
Linux 的进程控制块 PCB(process control block)本质是一个叫做 task_struct 的结构体,里边包括管理进程所需的各种信息,其中有一个结构体叫做 file ,我们将它叫做文件描述符表,里边有一个整形索引表,用于存储文件描述符。

内核为每一个进程维护了一个文件描述符表,索引表中的值都是从 0 开始的,所以在不同的进程中你会看到相同的文件描述符,但是它们指向的不一定是同一个磁盘文件。

  1. 每个进程对应的文件描述符表默认支持打开的最大文件数为 1024,可以修改
  2. 每个进程的文件描述符表中都已经默认分配了三个文件描述符,对应的都是当前终端文件(/dev/tty)
    • STDIN_FILENO:标准输入,可以通过这个文件描述符将数据输入到终端文件中,宏值为 0。
    • STDOUT_FILENO:标准输出,可以通过这个文件描述符将数据通过终端输出出来,宏值为 1。
    • STDERR_FILENO:标准错误,可以通过这个文件描述符将错误信息通过终端输出出来,宏值为 2。
    • 这三个默认分配的文件描述符是可以通过 close() 函数关闭掉,但是关闭之后当前进程也就不能和当前终端进行输入或者输出的信息交互了。
  3. 每打开新的文件,内核会从进程的文件描述符表中找到一个空闲的没有别占用的文件描述符与其进行关联,(从3开始分配)
  4. 文件描述符表中不同的文件描述符可以对应同一个磁盘文件
  5. 每个进程文件描述符表中的文件描述符值是唯一的,不会重复。不同进程间会重复

1.2 系统IO

每个系统都有自己的专属函数,我们习惯称其为系统函数。系统函数并不是内核函数,因为内核函数是不允许用户使用的系统函数就充当了二者之间的桥梁,这样用户就可以间接的完成某些内核操作了。

open

open是一个系统函数, 只能在linux系统中使用, windows不支持
fopen 是标准c库函数, 一般都可以跨平台使用, 可以这样理解:

  • 在linux中 fopen底层封装了Linux的系统API open
  • 在window中, fopen底层封装的是 window 的 api
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

// 打开一个已经存在的磁盘文件
int open(const char *pathname, int flags);
// 打开磁盘文件, 如果文件不存在, 就会自动创建
int open(const char *pathname, int flags, mode_t mode);

参数介绍:

  • pathname: 被打开的文件的文件名

  • flags: 使用什么方式打开指定的文件,这个参数对应一些宏值,需要根据实际需求指定必须要指定的属性 , 以下三个属性不能同时使用,只能任选其一

    O_RDONLY: 以只读方式打开文件
    O_WRONLY: 以只写方式打开文件
    O_RDWR: 以读写方式打开文件
    可选属性 , 和上边的属性一起使用
    O_APPEND: 新数据追加到文件尾部,不会覆盖文件的原来内容
    O_CREAT: 如果文件不存在,创建该文件,如果文件存在什么也不做
    O_EXCL: 检测文件是否存在,必须要和 O_CREAT 一起使用,不能单独使用: O_CREAT | O_EXCL
    检测到文件不存在,创建新文件
    检测到文件已经存在,创建失败,函数直接返回 - 1(如果不添加这个属性,不会返回 - 1)

  • mode: 在创建新文件的时候才需要指定这个参数的值,用于指定新文件的权限,这是一个八进制的整数,这个参数的最大值为:0777

close

如果需要释放这个文件描述符就需要关闭文件。对应的这个系统函数叫做 close,函数原型如下:

#include <unistd.h>
int close(int fd);
  • 函数参数: fd 是文件描述符,是 open () 函数的返回值
  • 函数返回值:函数调用成功返回值 0, 调用失败返回 -1

read

read 函数用于读取文件内部数据,在通过 open 打开文件的时候需要指定读权限,函数原型如下:

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);

参数:

  • fd: 文件描述符,open () 函数的返回值,通过这个参数定位打开的磁盘文件
  • buf: 是一个传出参数,指向一块有效的内存,用于存储从文件中读出的数据
  • count: buf 指针指向的内存的大小,指定可以存储的最大字节数

返回值:

  • 大于 0: 从文件中读出的字节数,读文件成功
  • 等于 0: 代表文件读完了,读文件成功
  • -1: 读文件失败了

write

write 函数用于将数据写入到文件内部,在通过 open 打开文件的时候需要指定写权限,函数原型如下:

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

参数:
fd: 文件描述符,open () 函数的返回值,通过这个参数定位打开的磁盘文件
buf: 指向一块有效的内存地址,里边有要写入到磁盘文件中的数据
count: 要往磁盘文件中写入的字节数,一般情况下就是 buf 字符串的长度,strlen (buf)

返回值:
大于 0: 成功写入到磁盘文件中的字节数
-1: 写文件失败了

lseek

系统函数 lseek 的功能是比较强大的,我们既可以通过这个函数移动文件指针,也可以通过这个函数进行文件的拓展。这个函数的原型如下:

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

off_t lseek(int fd, off_t offset, int whence);

参数:

  • fd: 文件描述符,open () 函数的返回值,通过这个参数定位打开的磁盘文件
  • offset: 偏移量,需要和第三个参数配合使用
  • whence: 通过这个参数指定函数实现什么样的功能
    • SEEK_SET: 从文件头部开始偏移 offset 个字节
    • SEEK_CUR: 从当前文件指针的位置向后偏移 offset 个字节
    • SEEK_END: 从文件尾部向后偏移 offset 个字节

返回值:

  • 成功:文件指针从头部开始计算总的偏移量
  • 失败: -1

移动文件指针的使用

  • 文件指针移动到文件头部 : lseek(fd, 0, SEEK_SET);
  • 得到当前文件指针的位置 : lseek(fd, 0, SEEK_CUR);
  • 得到文件总大小 : lseek(fd, 0, SEEK_END);

truncate/ftruncate

truncate/ftruncate 这两个函数的功能是一样的,可以对文件进行拓展也可以截断文件。使用这两个函数拓展文件比使用 lseek 要简单。这两个函数的函数原型如下:

// 拓展文件或截断文件
#include <unistd.h>
#include <sys/types.h>

int truncate(const char *path, off_t length);- 
int ftruncate(int fd, off_t length);

参数:

  • path: 要拓展 / 截断的文件的文件名
  • fd: 文件描述符,open () 得到的
  • length: 文件的最终大小
    • 文件原来 size > length,文件被截断,尾部多余的部分被删除,文件最终长度为 length
    • 文件原来 size < length,文件被拓展,文件最终长度为 length
  • 返回值:成功返回 0; 失败返回值 - 1

truncate () 和 ftruncate () 两个函数的区别在于一个使用文件名一个使用文件描述符操作文件,功能相同。
不管是使用这两个函数还是使用 lseek () 函数拓展文件,文件尾部填充的字符都是 0。

perror

通过 perror 函数将错误号对应的描述信息打印出来

#include <stdio.h>
// 参数, 自己指定这个字符串的值就可以, 指定什么就会原样输出, 除此之外还会输出错误号对应的描述信息
void perror(const char *s);	

1.3 文件的属性信息

Linux 是一个基于文件的操作系统,因此作为文件本身也就有很多属性。
如果想要查看某一个文件的属性有两种方式:命令和函数。虽然有两种方式但是它们对应的名字是相同的,叫做 stat。另外使用 file 命令也可以查看文件的一些属性信息。

file命令

$ file 文件名 [参数] 

在这里插入图片描述

stat命令

stat 命令显示文件或目录的详细属性信息包括文件系统状态,比 ls 命令输出的信息更详细。语法格式如下:

$ stat [参数] 文件或者目录名

在这里插入图片描述

stat/lstat 函数

stat/lstat 函数的功能和 stat 命令的功能是一样的,只不过是应用场景不同。这两个函数的区别在于处理软链接文件的方式上:

  • lstat (): 得到的是软连接文件本身的属性信息
  • stat (): 得到的是软链接文件关联的文件的属性信息
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

int stat(const char *pathname, struct stat *buf);
int lstat(const char *pathname, struct stat *buf);

参数:

  • pathname: 文件名,要获取这个文件的属性信息
  • buf: 传出参数,文件的信息被写入到了这块内存中
  • 返回值:函数调用成功返回 0,调用失败返回 -1
    这个函数的第二个参数是一个结构体类型,这个结构体相对复杂,通过这个结构体可以存储得到的文件的所有属性信息

1.4 文件描述符复制和重定向

Linux 系统还提供了一些其他的 API 用于文件描述符的分配

dup

dup 函数的作用是复制文件描述符,这样就有多个文件描述符可以指向同一个文件了。函数原型如下:

#include <unistd.h>
int dup(int oldfd);
  • 参数: oldfd 是要被复制的文件描述符
  • 返回值:函数调用成功返回被复制出的文件描述符,调用失败返回 -1
    在这里插入图片描述

dup2

dup2 () 函数是 dup () 函数的加强版,基于 dup2 () 既可以进行文件描述符的复制,也可以进行文件描述符的重定向。文件描述符重定向就是改变已经分配的文件描述符关联的磁盘文件。

  1. 文件描述符的复制, 和dup是一样的
  2. 能够重定向文件描述符

重定向: 改变文件描述符和文件的关联关系, 和新的文件建立关联关系, 和原来的文件断开关联关系

  1. 首先通过open()打开文件 a.txt , 得到文件描述符 fd
  2. 然后通过open()打开文件 b.txt , 得到文件描述符 fd1
  3. 将fd1从定向 到fd上:
    fd1和b.txt这磁盘文件断开关联, 关联到a.txt上, 以后fd和fd1都对用同一个磁盘文件 a.txt
#include <unistd.h>

int dup2(int oldfd, int newfd);
  • 参数: oldfd 和 newfd 都是文件描述符
  • 返回值:函数调用成功返回新的文件描述符,调用失败返回 -1

使用场景

  1. 假设参数 oldfd 对应磁盘文件 a.txt, newfd 对应磁盘文件 b.txt。在这种情况下调用 dup2 函数,是给 newfd 做了重定向,newfd 和文件 b.txt 断开关联, 相当于关闭了这个文件, 同时 newfd 指向了磁盘上的a.txt文件,最终 oldfd 和 newfd 都指向了磁盘文件 a.txt。

在这里插入图片描述
2. 假设参数 oldfd 对应磁盘文件 a.txt, newfd 不对应任何的磁盘文件(newfd 必须是一个大于等于 0 的整数)。在这种情况下调用 dup2 函数,在这种情况下会进行文件描述符的复制,newfd 指向了磁盘上的a.txt文件,最终 oldfd 和 newfd 都指向了磁盘文件 a.txt。
在这里插入图片描述
3. 假设参数 oldfd 和 newfd 两个文件描述符对应的是同一个磁盘文件 a.txt, 在这种情况下调用 dup2 函数,相当于啥也没发生,不会有任何改变。
在这里插入图片描述

fcntl函数

fcntl () 是一个变参函数,并且是多功能函数,在这里只介绍如何通过这个函数实现文件描述符的复制和获取/设置已打开的文件属性。该函数的函数原型如下:

#include <unistd.h>
#include <fcntl.h>	// 主要的头文件

int fcntl(int fd, int cmd, ... /* arg */ );

参数:

  • fd: 要操作的文件描述符
  • cmd: 通过该参数控制函数要实现什么功能

返回值 :函数调用失败返回 -1,调用成功,返回正确的值:

  • 参数 cmd = F_DUPFD:返回新的被分配的文件描述符
  • 参数 cmd = F_GETFL:返回文件的 flag 属性信息

在这里插入图片描述
文件的状态标志指的是在使用 open () 函数打开文件的时候指定的 flags 属性,也就是第二个参数

使用场景

  1. 使用 fcntl () 函数进行文件描述符复制,第二个参数 cmd 需要指定为 F_DUPFD(这是个变参函数其他参数不需要指定)。
int newfd = fcntl(fd, F_DUPFD);
  1. 通过 open() 函数打开文件之后,文件的 flag 属性就已经被确定下来了,如果想要在打开状态下修改这些属性,可以使用 fcntl() 函数实现,但是有一点需要注意,不是所有的 flag 属性都能被动态修改,只能修改如下状态标志: O_APPEND, O_NONBLOCK, O_SYNC, O_ASYNC, O_RSYNC 等。
// 得到文件的flag属性
int flag = fcntl(fd, F_GETFL);
// 添加新的flag 标志
flag = flag | O_APPEND;
// 将更新后的falg设置给文件
fcntl(fd, F_SETFL, flag);

二、进程

2.1 进程控制

进程命令

  • 查看进程
$ ps aux
	- a: 查看所有终端的信息
	- u: 查看用户相关的信息
	- x: 显示和终端无关的进程信息
  • 杀死进程
# 无条件杀死进程, 进程ID通过 ps aux 可以查看
$ kill -9 进程ID
$ kill -SIGKILL 进程ID

进程创建

Linux 中进程 ID 为 pid_t 类型,其本质是一个正整数,通过上边的 ps aux 命令已经得到了验证。PID 为 1 的进程是 Linux 系统中创建的第一个进程。

  • 获取当前进程的进程 ID(PID)
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
  • 获取当前进程的父进程 ID(PPID)
#include <sys/types.h>
#include <unistd.h>
pid_t getppid(void);
  • 创建一个新的进程
#include <unistd.h>
pid_t fork(void);

fork () 调用成功之后,会返回两个值,父子进程的返回值是不同的。

该函数调用成功之后,从一个虚拟地址空间变成了两个虚拟地址空间,每个地址空间中都会将 fork() 的返回值记录下来,这就是为什么会得到两个返回值的原因。

  • 父进程的虚拟地址空间中将该返回值标记为一个大于 0 的数(其实记录的是子进程的进程 ID)
  • 子进程的虚拟地址空间中将该返回值标记 0
  • 在程序中需要通过 fork () 的返回值来判断当前进程是子进程还是父进程。

在这里插入图片描述

exec族

有时候需要通过现在运行的进程启动磁盘上的另一个可执行程序,也就是通过一个进程启动另一个进程,这种情况下我们可以使用 exec族函数,函数原型如下:

#include <unistd.h>

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[]);

这些函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代(也就是说用户区数据基本全部被替换掉了),只留下进程 ID 等一些表面上的信息仍保持原样。只有调用失败了,它们才会返回一个 -1,从原程序的调用点接着往下执行。

execl函数

该函数可用于执行任意一个可执行程序,函数需要通过指定的文件路径才能找到这个可执行程序。

#include <unistd.h>
// 变参函数
int execl(const char *path, const char *arg, ...);

参数:

  • path: 要启动的可执行程序的路径,推荐使用绝对路径
  • arg: ps aux 查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同
  • … : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。

返回值 :如果这个函数执行成功,没有返回值,如果执行失败,返回 -1

execlp函数

该函数常用于执行已经设置了环境变量的可执行程序,函数中的 p 就是 path,也是说这个函数会自动搜索系统的环境变量 PATH,因此使用这个函数执行可执行程序不需要指定路径,只需要指定出名字即可。
参数:

  • file: 可执行程序的名字
    • 在环境变量 PATH 中,可执行程序可以不加路径
    • 没有在环境变量 PATH 中,可执行程序需要指定绝对路径
  • arg: ps aux 查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同
  • … : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。

返回值:如果这个函数执行成功,没有返回值,如果执行失败,返回 -1

结束进程

想要直接退出某个进程可以在程序的任何位置调用 exit() 或者_exit() 函数。函数的参数相当于退出码,如果参数值为 0 程序退出之后的状态码就是 0, 如果是 100 退出的状态码就是 100。

// 专门退出进程的函数, 在任何位置调用都可以
// 标准C库函数
#include <stdlib.h>
void exit(int status);

// Linux的系统函数
// 可以这么理解, 在linux中 exit() 函数 封装了 _exit()
#include <unistd.h>
void _exit(int status);

在 main 函数中直接使用 return 也可以退出进程,假如是在一个普通函数中调用 return 只能返回到调用者的位置,而不能退出进程

孤儿进程

在一个启动的进程中创建子进程,这时候父子进程同时运行,但是父进程由于某种原因先退出了,子进程还在运行,这时候这个子进程就可以被称之为孤儿进程(跟现实是一样的)。

操作系统当检测到某一个进程变成了孤儿进程,这时候系统中就会有一个固定的进程领养这个孤儿进程(有干爹了)。
这个领养孤儿进程的进程就是 init 进程(PID=1),如果有桌面终端,这个领养孤儿进程就是桌面进程。

系统为什么要领养这个孤儿进程呢?
在子进程退出的时候, 进程中的用户区可以自己释放, 但是进程内核区的pcb资源自己无法释放,必须要由父进程来释放子进程的pcb资源,孤儿进程被领养之后,这件事儿干爹就可以代劳了,这样可以避免系统资源的浪费。

僵尸进程

在一个启动的进程中创建子进程,父进程正常运行,子进程先与父进程结束,子进程无法释放自己的 PCB 资源,需要父进程来做这个件事儿,但是如果父进程也不管,这时候子进程就变成了僵尸进程。

僵尸进程已经死亡了,用户区资源已经被释放了,只是还占用着一些内核资源(PCB)。
僵尸进程的出现是由于这个已死亡的进程的父进程不作为造成的。

解决办法?
消灭僵尸进程的方法是,杀死这个僵尸进程的父进程,僵尸进程的父进程变成init ,这样僵尸进程的资源就被系统回收了。通过 kill -9 僵尸进程PID 的方式是不能消灭僵尸进程的,这个命令只对活着的进程有效,僵尸进程已经死了,鞭尸是不能解决问题的。

守护进程

护进程(Daemon Process),也就是通常说的 Daemon 进程(精灵进程),是 Linux 中的后台服务进程。它是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以 d 结尾的名字。

如果要创建一个守护进程,标准步骤如下,:

  1. 创建子进程,让父进程退出
  • 因为父进程有可能是组长进程,不符合条件,也没有什么利用价值,退出即可
  • 子进程没有任何职务,目的是让子进程最终变成一个会话,最终就会得到守护进程
  1. 通过子进程创建新的会话,调用函数 setsid (),脱离控制终端,变成守护进程

  2. 改变当前进程的工作目录 (可选项,不是必须要做的)

  • 某些文件系统可以被卸载,比如: U 盘,移动硬盘,进程如果在这些目录中运行,运行期间这些设备被卸载了,运行的进程也就不能正常工作了。

  • 修改当前进程的工作目录需要调用函数 chdir()

    int chdir(const char *path);
    
  1. 重新设置文件的掩码 (可选项,不是必须要做的)
  • 掩码: umask, 在创建新文件的时候需要和这个掩码进行运算,去掉文件的某些权限

  • 设置掩码需要使用函数 umask()

    mode_t umask(mode_t mask);
    
  1. 关闭 / 重定向文件描述符 (不做也可以,但是建议做一下)
  • 启动一个进程,文件描述符表中默认有三个被打开了,对应的都是当前的终端文件

  • 因为进程通过调用 setsid () 已经脱离了当前终端,因此关联的文件描述符也就没用了,可以关闭

    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);
    
  • 重定向文件描述符 (和关闭二选一): 改变文件描述符关联的默认文件,让他们指向一个特殊的文件 /dev/null,只要把数据扔到这个特殊的设备文件中,数据被被销毁了

    int fd = open("/dev/null", O_RDWR);
    // 重定向之后, 这三个文件描述符就和当前终端没有任何关系了
    dup2(fd, STDIN_FILENO);
    dup2(fd, STDOUT_FILENO);
    dup2(fd, STDERR_FILENO);
    
  1. 根据实际需求在守护进程中执行某些特定的操作

进程回收

为了避免僵尸进程的产生,一般我们会在父进程中进行子进程的资源回收,回收方式有两种,一种是阻塞方式 wait(),一种是非阻塞方式 waitpid()。

进程回收——wait函数

// man 2 wait
#include <sys/wait.h>
pid_t wait(int *status);

参数:传出参数,通过传递出的信息判断回收的进程是怎么退出的,如果不需要该信息可以指定为 NULL。取出整形变量中的数据需要使用一些宏函数,具体操作方式如下:

  • WIFEXITED(status): 返回 1, 进程是正常退出的
  • WEXITSTATUS(status):得到进程退出时候的状态码,相当于 return 后边的数值,或者 exit () 函数的参数
  • WIFSIGNALED(status): 返回 1, 进程是被信号杀死了
  • WTERMSIG(status): 获得进程是被哪个信号杀死的,会得到信号的编号

返回值:

  • 成功:返回被回收的子进程的进程 ID
  • 失败: -1
    • 没有子进程资源可以回收了,函数的阻塞会自动解除,返回 - 1
    • 回收子进程资源的时候出现了异常

进程回收——waitpid函数

该函数可以控制回收子进程资源的方式是阻塞还是非阻塞,另外还可以通过该函数进行精准打击,可以精确指定回收某个或者某一类或者是全部子进程资源

// man 2 waitpid
#include <sys/wait.h>
// 这个函数可以设置阻塞, 也可以设置为非阻塞
// 这个函数可以指定回收哪些子进程的资源
pid_t waitpid(pid_t pid, int *status, int options);

参数:

  • pid:

    • -1:回收所有的子进程资源,和 wait () 是一样的,无差别回收,并不是一次性就可以回收多个,也是需要循环回收的
    • 大于0:指定回收某一个进程的资源 ,pid 是要回收的子进程的进程 ID
    • 0:回收当前进程组的所有子进程 ID
    • 小于 -1:pid 的绝对值代表进程组 ID,表示要回收这个进程组的所有子进程资源
  • status: NULL, 和 wait 的参数是一样的

  • options: 控制函数是阻塞还是非阻塞

    • 0: 函数是行为是阻塞的 ==> 和 wait 一样
    • WNOHANG: 函数是行为是非阻塞的

返回值:

  • 如果函数是非阻塞的,并且子进程还在运行,返回 0
  • 成功:得到子进程的进程 ID
  • 失败: -1
    • 没有子进程资源可以回收了,函数如果是阻塞的,阻塞会解除,直接返回 - 1
    • 回收子进程资源的时候出现了异常

2.2 管道

管道的是进程间通信(IPC - InterProcess Communication)的一种方式,管道的本质其实就是内核中的一块内存 (或者叫内核缓冲区),这块缓冲区中的数据存储在一个环形队列中,因为管道在内核里边,因此我们不能直接对其进行任何操作。
在这里插入图片描述
因为管道数据是通过队列来维护的,我们先来分析一个管道中数据的特点:

  • 管道对应的内核缓冲区大小是固定的,默认为 4k(也就是队列最大能存储 4k 数据)

  • 管道分为两部分:读端和写端(队列的两端),数据从写端进入管道,从读端流出管道。

  • 管道中的数据只能读一次,做一次读操作之后数据也就没有了(读数据相当于出队列)。

  • 管道是单工的:数据只能单向流动,数据从写端流向读端。

  • 对管道的操作(读、写)默认是阻塞的

    • 读管道:管道中没有数据,读操作被阻塞,当管道中有数据之后阻塞才能解除
    • 写管道:管道被写满了,写数据的操作被阻塞,当管道变为不满的状态,写阻塞解除

管道在内核中,不能直接对其进行操作,我们通过什么方式去读写管道呢?
其实管道操作就是文件 IO 操作,内核中管道的两端分别对应两个文件描述符,通过写端的文件描述符把数据写入到管道中,通过读端的文件描述符将数据从管道中读出来。

// 读管道
ssize_t read(int fd, void *buf, size_t count);
// 写管道的函数
ssize_t write(int fd, const void *buf, size_t count);

管道通信

在这里插入图片描述
在上图中假设父进通过一系列操作可以通过文件描述符表中的文件描述符 fd3 写管道,通过 fd4 读管道,然后再通过 fork() 创建出子进程,那么在父进程中被分配的文件描述符 fd3, fd4也就被拷贝到子进程中,子进程通过 fd3可以将数据写入到内核的管道中,通过fd4将数据从管道中读出来。

也就是说管道是独立于任何进程的,并且充当了两个进程用于数据通信的载体,只要两个进程能够得到同一个管道的入口和出口(读端和写端的文件描述符),那么他们之间就可以通过管道进行数据的交互。

管道的数据是单向流动的:

  • 操作管道的是两个进程, 进程A读管道, 需要关闭管道的写端, 进程B写管道, 需要关闭管道的读端
  • 如果不做上述的操作, 会对程序的结果造成阻塞, 对管道的操作无法结束
  • 双向通信建立两个管道

匿名管道

匿名管道只能实现有血缘关系的进程间通信,比如:父子进程,兄弟进程,爷孙进程,叔侄进程。

创建匿名管道的函数,函数原型如下:

#include <unistd.h>
// 创建一个匿名的管道, 得到两个可用的文件描述符
int pipe(int pipefd[2]);

参数:传出参数,需要传递一个整形数组的地址,数组大小为 2,也就是说最终会传出两个元素

  • pipefd[0]: 对应管道读端的文件描述符,通过它可以将数据从管道中读出
  • pipefd[1]: 对应管道写端的文件描述符,通过它可以将数据写入到管道中

返回值:成功返回 0,失败返回 -1

有名管道

有名管道拥有管道的所有特性,之所以称之为有名是因为管道在磁盘上有实体文件,文件类型为 p ,有名管道文件大小永远为 0,因为有名管道也是将数据存储到内存的缓冲区中,打开这个磁盘上的管道文件就可以得到操作有名管道的文件描述符,通过文件描述符读写管道存储在内核中的数据。

创建有名管道

  1. 命令
$ mkfifo 有名管道的名字
  1. 函数
#include <sys/types.h>
#include <sys/stat.h>
// int open(const char *pathname, int flags, mode_t mode);
int mkfifo(const char *pathname, mode_t mode);

参数:

  • pathname: 要创建的有名管道的名字
  • mode: 文件的操作权限,和 open () 的第三个参数一个作用,最终权限: (mode & ~umask)

返回值:创建成功返回 0,失败返回 -1

管道的读写行为

关于管道不管是有名的还是匿名的,在进行读写的时候,它们表现出的行为是一致的,下面是对其读写行为的总结:

  • 读管道,需要根据写端的状态进行分析:
    • 写端没有关闭 (操作管道写端的文件描述符没有被关闭)
      • 如果管道中没有数据 ==> 读阻塞 , 如果管道中被写入了数据,阻塞解除
      • 如果管道中有数据 ==> 不阻塞,管道中的数据被读完了,再继续读管道还会阻塞
    • 写端已经关闭了 (没有可用的文件描述符可以写管道了)
      • 管道中没有数据 ==> 读端解除阻塞,read 函数返回 0
      • 管道中有数据 ==> read 先将数据读出,数据读完之后返回 0, 不会阻塞了
  • 写管道,需要根据读端的状态进行分析:
    • 读端没有关闭
      • 如果管道有存储的空间,一直写数据
      • 如果管道写满了,写操作就阻塞,当读端将管道数据读走了,解除阻塞继续写
    • 读端关闭了,管道破裂 (异常), 进程直接退出

2.3 内存映射(mmap)

如果想要实现进程间通信,可以通过函数创建一块内存映射区,和管道不同的是管道对应的内存空间在内核中,而内存映射区对应的内存空间在进程的用户区(用于加载动态库的那个区域),也就是说进程间通信使用的内存映射区不是一块,而是在每个进程内部都有一块。

由于每个进程的地址空间是独立的,各个进程之间也不能直接访问对方的内存映射区,需要通信的进程需要将各自的内存映射区和同一个磁盘文件进行映射,这样进程之间就可以通过磁盘文件这个唯一的桥梁完成数据的交互了。

在这里插入图片描述
使用内存映射区既可以进程有血缘关系的进程间通信也可以进程没有血缘关系的进程间通信。

创建内存映射区函数

创建内存映射区的函数原型如下

#include <sys/mman.h>
// 创建内存映射区
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

参数:

  • addr: 从动态库加载区的什么位置开始创建内存映射区,一般指定为 NULL, 委托内核分配

  • length: 创建的内存映射区的大小(单位:字节),实际上这个大小是按照 4k 的整数倍去分配的

  • prot: 对内存映射区的操作权限

    • PROT_READ: 读内存映射区

    • PROT_WRITE: 写内存映射区

    • 如果要对映射区有读写权限: PROT_READ | PROT_WRITE

  • flags:

    • MAP_SHARED: 多个进程可以共享数据,进行映射区数据同步
    • MAP_PRIVATE: 映射区数据是私有的,不能同步给其他进程
  • fd: 文件描述符,对应一个打开的磁盘文件,内存映射区通过这个文件描述符和磁盘文件建立关联

  • offset: 磁盘文件的偏移量,文件从偏移到的位置开始进行数据映射,使用这个参数需要注意两个问题:

    • 偏移量必须是 4k 的整数倍,写 0 代表不偏移
    • 这个参数必须是大于 0 的

返回值:

  • 成功:返回一个内存映射区的起始地址

  • 失败: MAP_FAILED (that is, (void *) -1)

释放内存映射区函数

释放内存映射区函数如下

int munmap(void *addr, size_t length);

参数:

  • addr: mmap () 的返回值,创建的内存映射区的起始地址
  • length: 和 mmap () 第二个参数相同即可
  • 返回值:函数调用成功返回 0,失败返回 -1

内存映射区进程间通信(利用同一个文件映射到两个内存,内存和文件数据同步)

需要在每个进程中分别创建内存映射区,但是这些进程的内存映射区必须要关联相同的磁盘文件,这样才能实现进程间的数据同步。

  1. 分别创建内存映射区,关联同一个文件, 得到一个起始地址, 假设使用ptr指针保存这个地址
    2. 进程A往自己的内存映射区写数据, 数据同步到了磁盘文件中, 磁盘文件数据又同步到子进程的映射区中
    3. 进程B从自己的映射区往外读数据, 这个数据就是进程A写的

内存映射拷贝文件

使用内存映射区拷贝文件思路:

  1. 打开被拷贝文件,得到文件描述符 fd1,并计算出这个文件的大小 size
  2. 创建内存映射区 A 并且和被拷贝文件关联,也就是和 fd1 关联起来,得到映射区地址 ptrA
  3. 创建新文件,得到文件描述符 fd2,用于存储被拷贝的数据,并且将这个文件大小拓展为 size
  4. 创建内存映射区 B 并且和新创建的文件关联,也就是和 fd2 关联起来,得到映射区地址 ptrB
  5. 进程地址空间之间的数据拷贝,memcpy(ptrB, ptrA,size),数据自动同步到新建文件中
  6. 关闭内存映射区

2.4 共享内存

共享内存不同于内存映射区,它不属于任何进程并且不受进程生命周期的影响
通过调用 Linux 提供的系统函数就可得到这块共享内存。使用之前需要让进程和共享内存进行关联,得到共享内存的起始地址之后就可以直接进行读写操作了,进程也可以和这块共享内存解除关联,解除关联之后就不能操作这块共享内存了。在所有进程间通信的方式中共享内存的效率是最高的。

共享内存操作默认不阻塞,如果多个进程同时读写共享内存,可能出现数据混乱,共享内存需要借助其他机制来保证进程间的数据同步,比如:信号量,共享内存内部没有提供这种机制。

创建/打开共享内存函数——shmget

在使用共享内存之前必须要先做一些准备工作,如果共享内存不存在就需要先创建出来,如果已经存在了就需要先打开这块共享内存。不管是创建还是打开共享内存使用的函数是同一个,函数原型如下:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

参数:

  • key: 类型 key_t 是个整形数,通过这个key可以创建或者打开一块共享内存,该参数的值一定要大于0
  • size: 创建共享内存的时候,指定共享内存的大小,如果是打开一块存在的共享内存,size 是没有意义的
  • shmflg:创建共享内存的时候指定的属性
    • IPC_CREAT: 创建新的共享内存,如果创建共享内存,需要指定对共享内存的操作权限,比如:IPC_CREAT | 0664
    • IPC_EXCL: 检测共享内存是否已经存在了,必须和 IPC_CREAT 一起使用

返回值:共享内存创建或者打开成功返回标识共享内存的唯一的 ID,失败返回 - 1

生成shmget函数key值的函数——ftok

shmget () 函数的第一个参数是一个大于 0 的正整数,如果不想自己指定可以通过 ftok () 函数直接生成这个 key 值。该函数的函数原型如下:

// ftok函数原型
#include <sys/types.h>
#include <sys/ipc.h>

// 将两个参数作为种子, 生成一个 key_t 类型的数值
key_t ftok(const char *pathname, int proj_id);

参数:

  • pathname: 当前操作系统中一个存在的路径

  • proj_id: 这个参数只用到了 int 中的一个字节,传参的时候要将其作为 char 进行操作,取值范围: 1-255

返回值:函数调用成功返回一个可用于创建、打开共享内存的 key 值,调用失败返回 - 1

shmget函数和ftok函数使用例子

// 根据路径生成一个key_t
key_t key = ftok("/home/robin", 'a');
// 创建或打开共享内存
shmget(key, 4096, IPC_CREATE|0664);

关联共享内存函数——shamt(把共享内存和进程的虚拟内存关联上,通过唯一的共享内存号)

创建 / 打开共享内存之后还必须和共享内存进行关联,这样才能得到共享内存的起始地址,通过得到的内存地址进行数据的读写操作,关联函数的原型如下:

void *shmat(int shmid, const void *shmaddr, int shmflg);

参数:

  • shmid: 要操作的共享内存的 ID, 是 shmget () 函数的返回值
  • shmaddr: 共享内存的起始地址,用户不知道,需要让内核指定,写 NULL
  • shmflg: 和共享内存关联的对共享内存的操作权限
    • SHM_RDONLY: 读权限,只能读共享内存中的数据
    • 0: 读写权限,可以读写共享内存数据

返回值:关联成功,返回值共享内存的起始地址,关联失败返回 (void *) -1

解除关联共享内存函数——shmdt

当进程不需要再操作共享内存,可以让进程和共享内存解除关联,另外如果没有执行该操作,进程退出之后,结束的进程和共享内存的关联也就自动解除了。

int shmdt(const void *shmaddr);
  • 参数:shmat () 函数的返回值,共享内存的起始地址
  • 返回值:关联解除成功返回 0,失败返回 - 1

删除共享内存——shmctl

shmctl () 函数是一个多功能函数,可以设置、获取共享内存的状态也可以将共享内存标记为删除状态

当共享内存被标记为删除状态之后,并不会马上被删除,直到所有的进程全部和共享内存解除关联,共享内存才会被删除。因为通过 shmctl () 函数只是能够标记删除共享内存,所以在程序中多次调用该操作是没有关系的。

// 共享内存控制函数
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

// 参数 struct shmid_ds 结构体原型          
struct shmid_ds {
    
    
	struct ipc_perm shm_perm;    /* Ownership and permissions */
	size_t          shm_segsz;   /* Size of segment (bytes) */
	time_t          shm_atime;   /* Last attach time */
	time_t          shm_dtime;   /* Last detach time */
	time_t          shm_ctime;   /* Last change time */
	pid_t           shm_cpid;    /* PID of creator */
	pid_t           shm_lpid;    /* PID of last shmat(2)/shmdt(2) */
    // 引用计数, 多少个进程和共享内存进行了关联
	shmatt_t        shm_nattch;  /* 记录了有多少个进程和当前共享内存进行了管联 */
	...
};

参数:

  • shmid: 要操作的共享内存的 ID, 是 shmget () 函数的返回值
  • cmd: 要做的操作
    • IPC_STAT: 得到当前共享内存的状态
    • IPC_SET: 设置共享内存的状态
    • IPC_RMID: 标记共享内存要被删除了
  • buf:
    • cmd==IPC_STAT, 作为传出参数,会得到共享内存的相关属性信息
    • cmd==IPC_SET, 作为传入参,将用户的自定义属性设置到共享内存中
    • cmd==IPC_RMID, buf 就没意义了,这时候 buf 指定为 NULL 即可

返回值:函数调用成功返回值大于等于 0,调用失败返回 - 1

共享内存进程间通信

  1. 调用linux的系统API(shmget)创建一块共享内存

    • 这块内存不属于任何进程, 默认进程不能对其进行操作
  2. 准备好进程A, 和进程B, 这两个进程需要和创建的共享内存进行关联

    • 关联操作: 调用linux的 api(shmat)
    • 关联成功之后, 得到了这块共享内存的起始地址
  3. 在进程A或者进程B中对共享内存进行读写操作

    • 读内存: printf() 等;
    • 写内存: memcpy() 等;
  4. 通信完成, 可以让进程A和B和共享内存解除关联

    • 解除成功, 进程A和B不能再操作共享内存了
    • 共享内存不受进程生命周期的影响的
  5. 共享内存不在使用之后, 将其删除

    • 调用linux的api函数, 删除之后这块内存被内核回收了

共享内存(shm)和内存映射区(mmap)的的区别

  1. 实现进程间通信的方式
  • 共享内存: 多个进程只需要一块共享内存就够了,共享内存不属于进程,需要和进程关联才能使用
  • 内存映射区:位于每个进程的虚拟地址空间中,并且需要关联同一个磁盘文件才能实现进程间数据通信
  1. 效率:
  • 共享内存: 直接对内存操作,效率高
  • 内存映射区:需要内存和文件之间的数据同步,效率低
  1. 生命周期
  • 内存映射区:进程退出,内存映射区也就没有了
  • 共享内存:进程退出对共享内存没有影响,调用相关函数 / 命令 / 关机才能删除共享内存
  1. 数据的完整性 -> 突发状态下数据能不能被保存下来(比如:突然断电)
  • 内存映射区:可以完整的保存数据,内存映射区数据会同步到磁盘文件
  • 共享内存:数据存储在物理内存中,断电之后系统关闭,内存数据也就丢失了

三、信号

3.1 信号概述

Linux 中的信号是一种消息处理机制,它本质上是一个整数,不同的信号对应不同的值,由于信号的结构简单所以天生不能携带很大的信息量,但是信号在系统中的优先级是非常高的。

信号也可以实现进程间通信,但是信号能传递的数据量很少,不能满足大部分需求,另外信号的优先级很高,并且它对应的处理动作是回调完成的,它会打乱程序原有的处理流程,影响到最终的处理结果。因此非常不建议使用信号进行进程间通信。

信号编号

kill -l 命令

# 执行shell命令查看信号
$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

查看信号信息

# 查看man文档的信号描述
$ man 7 signal

在信号描述中介绍了对产生的信号的五种默认处理动作,分别是:

  1. Term:信号将进程终止
  2. Ign:信号产生之后默认被忽略了
  3. Core:信号将进程终止,并且生成一个 core 文件 (一般用于 gdb 调试)
  4. Stop:信号会暂停进程的运行
  5. Cont:信号会让暂停的进程继续运行

关于对信号的介绍有一句非常重要的描述: 9号信号和19号信号不能被 捕捉, 阻塞, 和 忽略

  • 9号信号: 无条件杀死进程
  • 19号信号: 无条件暂停进程

信号的状态

Linux 中的信号有三种状态,分别为:产生,未决,递达

  • 产生:键盘输入,函数调用,执行 shell 命令,对硬件进行非法访问都会产生信号
  • 未决:信号产生了,但是这个信号还没有被处理掉,这个期间信号的状态称之为未决状态
  • 递达:信号被处理了 (被某个进程处理掉)

3.2 信号相关函数

kill/raise/abort

这三个函数的功能比较类似,可以发送相关的信号给到对应的进程

  1. kill 发送指定的信号到指定的进程,函数原型如下:
#include <signal.h>
// 给某一个进程发送一个信号
int kill(pid_t pid, int sig);

参数:

  • pid: 进程 ID(man 文档里边写的比较详细)
  • sig: 要发送的信号
  1. raise:给当前进程发送指定的信号,函数原型如下:
// 给自己发送某一个信号
#include <signal.h>
int raise(int sig);	// 参数就是要给当前进程发送的信号
  1. abort:给当前进程发送一个固定信号 (SIGABRT),函数原型如下:
// 这是一个中断函数, 调用这个函数, 发送一个固定信号 (SIGABRT), 杀死当前进程
#include <stdlib.h>
void abort(void);

定时器——alarm

alarm () 函数只能进行单次定时,定时完成发射出一个信号。

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
  • 参数:倒计时 seconds 秒,倒计时完成发送一个信号 SIGALRM , 当前进程会收到这个信号,这个信号默认的处理动作是中断当前进程
  • 返回值:大于 0 表示倒计时还剩多少秒,返回值为 0 表示倒计时完成,信号被发出

定时器——setitimer

setitimer () 函数可以进行周期性定时,每触发一次定时器就会发射出一个信号。

// 这个函数可以实现周期性定时, 每个一段固定的时间, 发出一个特定的定时器信号
#include <sys/time.h>

struct itimerval {
    
    
	struct timeval it_interval; /* 时间间隔 */
	struct timeval it_value;    /* 第一次触发定时器的时长 */
};
//  - it_value: 当前设置闹钟的时间点 到 明天早晨7点 对应的总秒数
//  - it_interval: 闹钟第一次响过之后, 每隔5分钟响一次

// 这个结构体表示的是一个时间段: tv_sec + tv_usec
struct timeval {
    
    
	time_t      tv_sec;         /* 秒 */
	suseconds_t tv_usec;        /* 微妙 */
};

int setitimer(int which, 
			const struct itimerval *new_value, 
              struct itimerval *old_value);

参数:

  • which: 定时器使用什么样的计时法则,不同的计时法则发出的信号不同
    • ITIMER_REAL: 自然计时法,最常用,发出的信号为 SIGALRM, 一般使用这个宏值,自然计时法时间 = 用户区 + 内核 + 消耗的时间 (从进程的用户区到内核区切换使用的总时间)
    • ITIMER_VIRTUAL: 只计算程序在用户区运行使用的时间,发射的信号为 SIGVTALRM
    • ITIMER_PROF: 只计算内核运行使用的时间,发出的信号为 SIGPROF
  • new_value: 给定时器设置的定时信息,传入参数
  • old_value: 上一次给定时器设置的定时信息,传出参数,如果不需要这个信息,指定为 NULL

3.3 信号集

阻塞 / 未决信号集

PCB 中有两个非常重要的信号集。一个称之为 “阻塞信号集”,另一个称之为 “未决信号集”。这两个信号集体现在内核中就是两张表。但是操作系统不允许我们直接对这两个信号集进行任何操作,而是需要自定义另外一个集合,借助信号集操作函数来对 PCB 中的这两个信号集进行修改。

  • 信号的 “未决” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间。

  • 信号的 “阻塞” 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。

信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了 防止信号打断某些敏感的操作。

在这里插入图片描述

阻塞信号集和未决信号集在内核中的结构是相同的,它们都是一个整形数组 (被封装过的), 一共 128 字节 (int [32] == 1024 bit),1024 个标志位,其中前 31 个标志位,每一个都对应一个 Linux 中的标准信号,通过标志位的值来标记当前信号在信号集中的状态。

  • 在阻塞信号集中,描述这个信号有没有被阻塞

    • 默认情况下没有信号是被阻塞的,因此信号对应的标志位的值为 0
    • 如果某个信号被设置为了阻塞状态,这个信号对应的标志位 被设置为 1
  • 在未决信号集中,描述信号是否处于未决状态

    • 如果这个信号被阻塞了,不能处理,这个信号对应的标志位被设置为 1
    • 如果这个信号的阻塞被解除了,未决信号集中的这个信号马上就被处理了,这个信号对应的标志位值变为 0
    • 如果这个信号没有阻塞,信号产生之后直接被处理,因此不会在未决信号集中做任何记录

信号集函数

因为用户是不能直接操作内核中的阻塞信号集和未决信号集的,必须要调用系统函数:

  • 阻塞信号集可以通过系统函数进行读写操作
  • 未决信号集只能对其进行读操作。

读 / 写阻塞信号集的函数:

#include <signal.h>
// 使用这个函数修改内核中的阻塞信号集
// sigset_t 被封装之后得到的数据类型, 原型:int[32], 里边一共有1024给标志位, 每一个信号对应一个标志位
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数:

  • how:
    • SIG_BLOCK: 将参数 set 集合中的数据追加到阻塞信号集中
    • SIG_UNBLOCK: 将参数 set 集合中的信号在阻塞信号集中解除阻塞
    • SIG_SETMASK: 使用参 set 结合中的数据覆盖内核的阻塞信号集数据
    • oldset: 通过这个参数将设置之前的阻塞信号集数据传出,如果不需要可以指定为 NULL
      返回值:函数调用成功返回 0,调用失败返回 - 1

sigprocmask () 函数有一个 sigset_t 类型的参数,对这种类型的数据进行初始化需要调用一些相关的操作函数

#include <signal.h>
// 如果在程序中读写 sigset_t 类型的变量
// 阻塞信号集和未决信号集都存储在 sigset_t 类型的变量中, 这个变量对应一块内存
// 阻塞信号集和未决信号集, 对应的内存中有1024bit = 128字节

// 将set集合中所有的标志位设置为0
int sigemptyset(sigset_t *set);
// 将set集合中所有的标志位设置为1
int sigfillset(sigset_t *set);
// 将set集合中某一个信号(signum)对应的标志位设置为1
int sigaddset(sigset_t *set, int signum);
// 将set集合中某一个信号(signum)对应的标志位设置为0
int sigdelset(sigset_t *set, int signum);
// 判断某个信号在集合中对应的标志位到底是0还是1, 如果是0返回0, 如果是1返回1
int sigismember(const sigset_t *set, int signum);

在这里插入图片描述
未决信号集不需要程序猿修改,如果设置了某个信号阻塞,当这个信号产生之后,内核会将这个信号的未决状态记录到未决信号集中,当阻塞的信号被解除阻塞,未决信号集中的信号随之被处理,内核再次修改未决信号集将该信号的状态修改为递达状态(标志位置 0)

因此,写未决信号集的动作都是内核做的,这是一个读未决信号集的操作函数

#include <signal.h>
// 这个函数的参数是传出参数, 传出的内核未决信号集的拷贝
// 读一下这个集合就指定哪个信号是未决状态
int sigpending(sigset_t *set);

在这里插入图片描述

3.4 信号捕捉

Linux 中的每个信号产生之后都会有对应的默认处理行为,如果想要忽略这个信号或者修改某些信号的默认行为就需要在程序中捕捉该信号。

程序中进行信号捕捉可以看做是一个注册的动作,提前告诉应用程序信号产生之后做什么样的处理,当进程中对应的信号产生了,这个处理动作也就被调用了。

signal

使用 signal () 函数可以捕捉进程中产生的信号,并且修改捕捉到的函数的行为,这个信号的自定义处理动作是一个回调函数,内核通过 signal () 得到这个回调函数的地址,在信号产生之后该函数会被内核调用。

#include <signal.h>
// 在程序中什么时候产生信号, 程序猿是不知道的, 因此不能在信号产生之后再去处理
// 在信号产生之前, 提供一个注册函数, 用来捕捉信号
//	  - 假设在将来这个信号产生了, 就委托内核进行捕捉, 这个信号的默认动作就不能被执行
//	  - 执行什么样的处理动作 ==> 在signal函数中指定的处理动作
//	  - 如果这个信号不产生, 回调函数永远不会被调用
sighandler_t signal(int signum, sighandler_t handler);   

参数:

  • signum: 需要捕捉的信号

  • handler: 信号捕捉到之后的处理动作,这是一个函数指针,函数原型
    这个回调函数是需要程序猿写,由内核调用,内核调用回调函数的时候,会给它传递一个实参,这个实参的值就是捕捉的那个信号值。

    typedef void (*sighandler_t)(int);
    

sigaction

sigaction () 函数和 signal () 函数的功能是一样的,用于捕捉进程中产生的信号,并将用户自定义的信号行为函数(回调函数)注册给内核,内核在信号产生之后调用这个处理动作。sigaction () 可以看做是 signal () 函数是加强版,函数参数更多更复杂,函数功能也更强一些。函数原型如下:

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

参数:

  • signum: 要捕捉的信号

  • act: 捕捉到信号之后的处理动作

  • oldact: 上一次调用该函数进行信号捕捉设置的信号处理动作,该参数一般指定为 NULL

返回值:函数调用成功返回 0,失败返回 - 1

函数的参数是一个结构体类型,结构体原型如下:

struct sigaction {
    
    
	void     (*sa_handler)(int);    // 指向一个函数(回调函数)
	void     (*sa_sigaction)(int, siginfo_t *, void *);
	sigset_t   sa_mask;             // 初始化为空即可, 处理函数执行期间不屏蔽任何信号
	int        sa_flags;	        // 0
	void     (*sa_restorer)(void);  //不用
};

结构体成员介绍

  • sa_handler: 函数指针,指向的函数就是捕捉到的信号的处理动作

  • sa_sigaction: 函数指针,指向的函数就是捕捉到的信号的处理动作

  • sa_mask: 在信号处理函数执行期间,临时屏蔽某些信号 , 将要屏蔽的信号设置到集合中即可

    • 当前处理函数执行完毕,临时屏蔽自动解除
    • 假设在这个集合中不屏蔽任何信号,默认也会屏蔽一个(捕捉的信号是谁,就临时屏蔽谁)
  • sa_flags:使用哪个函数指针指向的函数处理捕捉到的信号

    • 0:使用 sa_handler (一般情况下使用这个)
    • SA_SIGINFO:使用 sa_sigaction (使用信号传递数据 == 进程间通信)
  • sa_restorer: 被废弃的成员

四、多线程

4.1 线程概述

线程是轻量级的进程(LWP:light weight process),在 Linux 环境下线程的本质仍是进程。操作系统会以进程为单位,分配系统资源,可以这样理解,进程是资源分配的最小单位,线程是操作系统调度执行的最小单位

线程和进程之间的区别

  • 进程有自己独立的地址空间,多个线程共用同一个地址空间

    • 线程更加节省系统资源,效率不仅可以保持的,而且能够更高
    • 在一个地址空间中多个线程独享:每个线程都有属于自己的栈区,寄存器 (内核中管理的)
    • 在一个地址空间中多个线程共享:代码段,堆区,全局数据区,打开的文件 (文件描述符表) 都是线程共享的
  • 线程是程序的最小执行单位,进程是操作系统中最小的资源分配单位

    • 每个进程对应一个虚拟地址空间,一个进程只能抢一个 CPU 时间片
    • 一个地址空间中可以划分出多个线程,在有效的资源基础上,能够抢更多的 CPU 时间片
  • CPU 的调度和切换:线程的上下文切换比进程要快的多

    • 上下文切换:进程 / 线程分时复用 CPU 时间片,在切换之前会将上一个任务的状态进行保存,下次切换回这个任务的时候,加载这个状态继续运行,任务从保存到再次加载这个过程就是一次上下文切换。

    • 线程更加廉价,启动速度更快,退出也快,对系统资源的冲击小。

  • 在处理多任务程序的时候使用多线程比使用多进程要更有优势,但是线程并不是越多越好,如何控制线程的个数呢?

    • 文件 IO 操作:文件 IO 对 CPU 是使用率不高,因此可以分时复用 CPU 时间片,线程的个数 = 2 * CPU 核心数 (效率最高)

    • 处理复杂的算法 (主要是 CPU 进行运算,压力大),线程的个数 = CPU 的核心数 (效率最高)

4.2 创建线程

每一个线程都有一个唯一的线程 ID,ID 类型为 pthread_t,这个 ID 是一个无符号长整形数,如果想要得到当前线程的线程 ID,可以调用如下函数:

pthread_t pthread_self(void);	// 返回当前线程的线程ID

在一个进程中调用线程创建函数,就可得到一个子线程,和进程不同,需要给每一个创建出的线程指定一个处理函数,否则这个线程无法工作。

#include <pthread.h>
int pthread_create(pthread_t *thread, 
					const pthread_attr_t *attr,
                   void *(*start_routine) (void *), 
                   void *arg);
// Compile and link with -pthread, 线程库的名字叫pthread, 全名: libpthread.so libptread.a

参数:

  • thread: 传出参数,是无符号长整形数,线程创建成功,会将线程 ID 写入到这个指针指向的内存中

  • attr: 线程的属性,一般情况下使用默认属性即可,写 NULL

  • start_routine: 函数指针,创建出的子线程的处理动作,也就是该函数在子线程中执行。

  • arg: 作为实参传递到 start_routine 指针指向的函数内部

返回值:线程创建成功返回 0,创建失败返回对应的错误号

主线程和子线程的生命周期?
子线程被创建出来之后需要抢cpu时间片, 抢不到就不能运行,如果主线程退出了, 虚拟地址空间就被释放了, 子线程就一并被销毁了。但是如果某一个子线程退出了, 主线程仍在运行, 虚拟地址空间依旧存在。

得到的结论:在没有人为干预的情况下,虚拟地址空间的生命周期和主线程是一样的,与子线程无关

4.3 线程退出

在编写多线程程序的时候,如果想要让线程退出,但是不会导致虚拟地址空间的释放(针对于主线程),我们就可以调用线程库中的线程退出函数,只要调用该函数当前线程就马上退出了,并且不会影响到其他线程的正常运行,不管是在子线程或者主线程中都可以使用。

#include <pthread.h>
void pthread_exit(void *retval);

参数:线程退出的时候携带的数据,当前子线程的主线程会得到该数据。如果不需要使用,指定为 NULL

4.4 线程回收

线程和进程一样,子线程退出的时候其内核资源主要由主线程回收,线程库中提供的线程回收函叫做 pthread_join(),这个函数是一个阻塞函数,如果还有子线程在运行,调用该函数就会阻塞,子线程退出函数解除阻塞进行资源的回收,函数被调用一次,只能回收一个子线程,如果有多个子线程则需要循环进行回收。

#include <pthread.h>
// 这是一个阻塞函数, 子线程在运行这个函数就阻塞
// 子线程退出, 函数解除阻塞, 回收对应的子线程资源, 类似于回收进程使用的函数 wait()
int pthread_join(pthread_t thread, void **retval);

参数:

  • thread: 要被回收的子线程的线程 ID

  • retval: 二级指针,指向一级指针的地址,是一个传出参数,这个地址中存储了 pthread_exit () 传递出的数据,如果不需要这个参数,可以指定为 NULL

返回值:线程回收成功返回 0,回收失败返回错误号。

4.5 线程分离

在线程库函数中为我们提供了线程分离函数 pthread_detach(),调用这个函数之后指定的子线程就可以和主线程分离,当子线程退出的时候,其占用的内核资源就被系统的其他进程接管并回收了。线程分离之后在主线程中使用 pthread_join() 就回收不到子线程资源了。

#include <pthread.h>
// 参数就子线程的线程ID, 主线程就可以和这个子线程分离了
int pthread_detach(pthread_t thread);

4.6 线程取消

线程取消的意思就是在某些特定情况下在一个线程中杀死另一个线程。使用这个函数杀死一个线程需要分两步:

  1. 在线程 A 中调用线程取消函数 pthread_cancel,指定杀死线程 B,这时候线程 B 是死不了的
  2. 在线程 B 中进程一次系统调用(从用户区切换到内核区),否则线程 B 可以一直运行。
#include <pthread.h>
// 参数是子线程的线程ID
int pthread_cancel(pthread_t thread);
  • 参数:要杀死的线程的线程 ID
  • 返回值:函数调用成功返回 0,调用失败返回非 0 错误号。

五、线程同步

所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的

5.1 线程同步方式

对于多个线程访问共享资源出现数据混乱的问题,需要进行线程同步。常用的线程同步方式有四种:互斥锁、读写锁、条件变量、信号量。所谓的共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对应的共享资源也被称之为临界资源
在这里插入图片描述
找到临界资源之后,再找和临界资源相关的上下文代码,这样就得到了一个代码块,这个代码块可以称之为临界区。确定好临界区(临界区越小越好)之后,就可以进行线程同步了,线程同步的大致处理思路是这样的

  • 在临界区代码的上边,添加加锁函数,对临界区加锁。
    • 哪个线程调用这句代码,就会把这把锁锁上,其他线程就只能阻塞在锁上了。
  • 在临界区代码的下边,添加解锁函数,对临界区解锁。
    • 出临界区的线程会将锁定的那把锁打开,其他抢到锁的线程就可以进入到临界区了。
  • 通过锁机制能保证临界区代码最多只能同时有一个线程访问,这样并行访问就变为串行访问了。

5.2 互斥锁

互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块,被锁定的这个代码块,所有的线程只能顺序执行 (不能并行处理),这样多线程访问共享资源数据混乱的问题就可以被解决了,需要付出的代价就是执行效率的降低,因为默认临界区多个线程是可以并行处理的,现在只能串行处理。

在 Linux 中互斥锁的类型为 pthread_mutex_t,创建一个这种类型的变量就得到了一把互斥锁:

pthread_mutex_t  mutex;

在创建的锁对象中保存了当前这把锁的状态信息锁定还是打开,如果是锁定状态还记录了给这把锁加锁的线程信息(线程 ID)。

一个互斥锁变量只能被一个线程锁定,被锁定之后其他线程再对互斥锁变量加锁就会被阻塞,直到这把互斥锁被解锁,被阻塞的线程才能被解除阻塞。一般情况下,每一个共享资源对应一个把互斥锁,锁的个数和线程的个数无关

// 初始化互斥锁
// restrict: 是一个关键字, 用来修饰指针, 只有这个关键字修饰的指针可以访问指向的内存地址, 其他指针是不行的
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
           				const pthread_mutexattr_t *restrict attr);
// 释放互斥锁资源            
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:

  • mutex: 互斥锁变量的地址
  • attr: 互斥锁的属性,一般使用默认属性即可,这个参数指定为 NULL

加锁函数(加锁失败阻塞)

// 修改互斥锁的状态, 将其设定为锁定状态, 这个状态被写入到参数 mutex 中
int pthread_mutex_lock(pthread_mutex_t *mutex);

这个函数被调用,首先会判断参数 mutex 互斥锁中的状态是不是锁定状态:

  • 没有被锁定,是打开的,这个线程可以加锁成功,这个这个锁中会记录是哪个线程加锁成功了
  • 如果被锁定了,其他线程加锁就失败了,这些线程都会阻塞在这把锁上
  • 当这把锁被解开之后,这些阻塞在锁上的线程就解除阻塞了,并且这些线程是通过竞争的方式对这把锁加锁,没抢到锁的线程继续阻塞

尝试加锁函数(加锁失败非阻塞)

// 尝试加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);

调用这个函数对互斥锁变量加锁还是有两种情况:

  • 如果这把锁没有被锁定是打开的,线程加锁成功
  • 如果锁变量被锁住了,调用这个函数加锁的线程,不会被阻塞,加锁失败直接返回错误号

解锁函数

// 对互斥锁解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);

5.3 死锁

当多个线程访问共享资源,需要加锁,如果锁使用不当,就会造成死锁这种现象。如果线程死锁造成的后果是:所有的线程都被阻塞,并且线程的阻塞是无法解开的(因为可以解锁的线程也被阻塞了)。

死锁的场景

  1. 加锁之后忘记解锁
  2. 重复加锁,造成死锁
  3. 在程序中有多个共享资源,因此有很多把锁,随意加锁,导致相互被阻塞

    场景描述:

    1. 有两个共享资源:X, Y,X对应锁A, Y对应锁B
      - 线程A访问资源X, 加锁A
      - 线程B访问资源Y, 加锁B
    2. 线程A要访问资源Y, 线程B要访问资源X,因为资源X和Y已经被对应的锁锁住了,因此这个两个线程被阻塞
      - 线程A被锁B阻塞了, 无法打开A锁
      - 线程B被锁A阻塞了, 无法打开B锁
      在这里插入图片描述

避免死锁

  • 避免多次锁定,多检查

  • 对共享资源访问完毕之后,一定要解锁,或者在加锁的使用 trylock

  • 如果程序中有多把锁,可以控制对锁的访问顺序 (顺序访问共享资源,但在有些情况下是做不到的),另外也可以在对其他互斥锁做加锁操作之前,先释放当前线程拥有的互斥锁。

  • 项目程序中可以引入一些专门用于死锁检测的模块

5.4 读写锁

在做读操作的时候可以提高程序的执行效率,如果所有的线程都是做读操作, 那么读是并行的

读写锁是一把锁,锁的类型为 pthread_rwlock_t,有了类型之后就可以创建一把互斥锁了

pthread_rwlock_t rwlock;

之所以称其为读写锁,是因为这把锁既可以锁定读操作,也可以锁定写操作。为了方便理解,可以大致认为在这把锁中记录了这些信息:

  • 锁的状态:锁定 / 打开
  • 锁定的是什么操作:读操作 / 写操作,使用读写锁锁定了读操作,需要先解锁才能去锁定写操作,反之亦然。
  • 哪个线程将这把锁锁上了

读写锁的使用方式也互斥锁的使用方式是完全相同的:找共享资源,确定临界区,在临界区的开始位置加锁(读锁 / 写锁),临界区的结束位置解锁。

因为通过一把读写锁可以锁定读或者写操作,下面介绍一下关于读写锁的特点

  1. 使用读写锁的读锁锁定了临界区,线程对临界区的访问是并行的,读锁是共享的。
  2. 使用读写锁的写锁锁定了临界区,线程对临界区的访问是串行的,写锁是独占的。
  3. 使用读写锁分别对两个临界区加了读锁和写锁,两个线程要同时访问者两个临界区,访问写锁临界区的线程继续运行,访问读锁临界区的线程阻塞,因为写锁比读锁的优先级高。

如果说程序中所有的线程都对共享资源做写操作,使用读写锁没有优势,和互斥锁是一样的,如果说程序中所有的线程都对共享资源有写也有读操作,并且对共享资源读的操作越多,读写锁更有优势。

读写锁的操作

#include <pthread.h>
pthread_rwlock_t rwlock;
// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
           const pthread_rwlockattr_t *restrict attr);
// 释放读写锁占用的系统资源
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

参数:

  • rwlock: 读写锁的地址,传出参数
  • attr: 读写锁属性,一般使用默认属性,指定为 NULL
// 在程序中对读写锁加读锁, 锁定的是读操作
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数的线程会被阻塞。

// 这个函数可以有效的避免死锁
// 如果加读锁失败, 不会阻塞当前线程, 直接返回错误号
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数加锁失败,对应的线程不会被阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。

// 在程序中对读写锁加写锁, 锁定的是写操作
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数的线程会被阻塞。

// 这个函数可以有效的避免死锁
// 如果加写锁失败, 不会阻塞当前线程, 直接返回错误号
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数加锁失败,但是线程不会阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。

// 解锁, 不管锁定了读还是写都可用解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

5.5 条件变量

条件变量的主要作用不是处理线程同步,而是进行线程的阻塞。如果在多线程程序中只使用条件变量无法实现线程的同步,必须要配合互斥锁来使用。虽然条件变量和互斥锁都能阻塞线程,但是二者的效果是不一样的,二者的区别如下:

  • 假设有 A-Z 26 个线程,这 26 个线程共同访问同一把互斥锁,如果线程 A 加锁成功,那么其余 B-Z 线程访问互斥锁都阻塞,所有的线程只能顺序访问临界区
  • 条件变量只有在满足指定条件下才会阻塞线程,如果条件不满足,多个线程可以同时进入临界区,同时读写临界资源,这种情况下还是会出现共享资源中数据的混乱。

条件变量类型对应的类型为 pthread_cond_t,这样就可以定义一个条件变量类型的变量了

pthread_cond_t cond;
//被条件变量阻塞的线程的线程信息会被记录到这个变量中,以便在解除阻塞的时候使用。
#include <pthread.h>
pthread_cond_t cond;
// 初始化
int pthread_cond_init(pthread_cond_t *restrict cond,
      const pthread_condattr_t *restrict attr);
// 销毁释放资源        
int pthread_cond_destroy(pthread_cond_t *cond);

参数:

  • cond: 条件变量的地址
  • attr: 条件变量属性,一般使用默认属性,指定为 NULL
// 线程阻塞函数, 哪个线程调用这个函数, 哪个线程就会被阻塞
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

通过函数原型可以看出,该函数在阻塞线程的时候,需要一个互斥锁参数,这个互斥锁主要功能是进行线程同步,让线程顺序进入临界区,避免出现数共享资源的数据混乱。该函数会对这个互斥锁做以下几件事情:

  1. 在阻塞线程时候,如果线程已经对互斥锁 mutex 上锁,那么会将这把锁打开,这样做是为了避免死锁
  2. 当线程解除阻塞的时候,函数内部会帮助这个线程再次将这个 mutex 互斥锁锁上,继续向下访问临界区
// 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示
struct timespec {
    
    
	time_t tv_sec;      /* Seconds */
	long   tv_nsec;     /* Nanoseconds [0 .. 999999999] */
};
// 将线程阻塞一定的时间长度, 时间到达之后, 线程就解除阻塞了
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
           pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);

这个函数的前两个参数和 pthread_cond_wait 函数是一样的,第三个参数表示线程阻塞的时长,但是需要额外注意一点:struct timespec 这个结构体中记录的时间是从1971.1.1到某个时间点的时间,总长度使用秒/纳秒表示。因此赋值方式相对要麻烦一点:

time_t mytim = time(NULL);	// 1970.1.1 0:0:0 到当前的总秒数
struct timespec tmsp;
tmsp.tv_nsec = 0;
tmsp.tv_sec = time(NULL) + 100;	// 线程阻塞100s
// 唤醒阻塞在条件变量上的线程, 至少有一个被解除阻塞
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒阻塞在条件变量上的线程, 被阻塞的线程全部解除阻塞
int pthread_cond_broadcast(pthread_cond_t *cond);

调用上面两个函数中的任意一个,都可以换线被 pthread_cond_wait 或者 pthread_cond_timedwait 阻塞的线程,区别就在于 pthread_cond_signal 是唤醒至少一个被阻塞的线程(总个数不定),pthread_cond_broadcast 是唤醒所有被阻塞的线程。

5.6 信号量

信号量(信号灯)与互斥锁和条件变量的主要不同在于” 灯” 的概念,灯亮则意味着资源可用,灯灭则意味着不可用。信号量主要阻塞线程,不能完全保证线程安全,如果要保证线程安全,需要信号量和互斥锁一起使用。

信号的类型为 sem_t 对应的头文件为 <semaphore.h>:

#include <semaphore.h>
sem_t sem;

信号量操作函数原型如下:

#include <semaphore.h>
// 初始化信号量/信号灯
int sem_init(sem_t *sem, int pshared, unsigned int value);
// 资源释放, 线程销毁之后调用这个函数即可
// 参数 sem 就是 sem_init() 的第一个参数            
int sem_destroy(sem_t *sem);

参数:

  • sem:信号量变量地址
  • pshared:
    • 0:线程同步
    • 非 0:进程同步
  • value:初始化当前信号量拥有的资源数(>=0),如果资源数为 0,线程就会被阻塞了。
// 参数 sem 就是 sem_init() 的第一个参数  
// 函数被调用sem中的资源就会被消耗1个, 资源数-1
int sem_wait(sem_t *sem);

当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,因此线程也就被阻塞了。

// 参数 sem 就是 sem_init() 的第一个参数  
// 函数被调用sem中的资源就会被消耗1个, 资源数-1
int sem_trywait(sem_t *sem);

当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,但是线程不会被阻塞,直接返回错误号,因此可以在程序中添加判断分支,用于处理获取资源失败之后的情况。

// 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示
struct timespec {
    
    
	time_t tv_sec;      /* Seconds */
	long   tv_nsec;     /* Nanoseconds [0 .. 999999999] */
};
// 调用该函数线程获取sem中的一个资源,当资源数为0时,线程阻塞,在阻塞abs_timeout对应的时长之后,解除阻塞。
// abs_timeout: 阻塞的时间长度, 单位是s, 是从1970.1.1开始计算的
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

该函数的参数 abs_timeout 和 pthread_cond_timedwait 的最后一个参数是一样的,使用方法不再过多赘述。当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,线程被阻塞,当阻塞指定的时长之后,线程解除阻塞。

// 调用该函数给sem中的资源数+1
int sem_post(sem_t *sem);

调用该函数会将 sem 中的资源数 +1,如果有线程在调用 sem_wait、sem_trywait、sem_timedwait 时因为 sem 中的资源数为 0 被阻塞了,这时这些线程会解除阻塞,获取到资源之后继续向下运行。

// 查看信号量 sem 中的整形数的当前值, 这个值会被写入到sval指针对应的内存中
// sval是一个传出参数
int sem_getvalue(sem_t *sem, int *sval);

通过这个函数可以查看 sem 中现在拥有的资源个数,通过第二个参数 sval 将数据传出,也就是说第二个参数的作用和返回值是一样的。

猜你喜欢

转载自blog.csdn.net/qq_44814825/article/details/129814977