学习笔记|Linux 系统编程
本文最后更新于:4 years ago
本文从实际运用的角度出发,系统描述 Linux/C++ 开发相关知识。
1.gcc/g++
以下内容将 gcc 替换成 g++ 同样适用。
1.1 编译过程
graph LR
A(hello.c) -->|预处理器 cpp| B(hello.i)
B -->|编译器 gcc| C(hello.s 汇编文件)
C -->|汇编器 as| D(hello.o 目标文件)
D -->|链接器 ld| E(a.out 二进制文件)
A1(hello.c) -->|gcc -E| B1(hello.i)
B1 -->|gcc -S| C1(hello.s 汇编文件)
C1 -->|gcc -c| D1(hello.o 目标文件)
D1 -->|gcc| E1(a.out 二进制文件)
1.2 参数
1.2.1 -I
问题情景:包含的 include 文件的路径不正确。
目录结构如下,但是在 hello.cc 中,头文件写的是 include "my.h"
。
1 |
|
- 解决方案一:更改所写头文件的路径。
将 include "my.h"
改为 include "./my_lib/my.h"
。
- 解决方案二:加入
-I
参数进行编译。
-I
参数指明调用头文件所在目录。(只用指明目录,无需写出完整路径)
1 |
|
1.2.2 -D
指定宏定义。
在 多个cpp文件 中加入调试信息时,为了控制是否打印调试信息,可以使用宏定义来控制。
1 |
|
编译时,通过控制 -D
参数来控制宏定义。
1 |
|
1.2.3 -O
指明优化选项,用来对编译时间,目标文件长度,执行效率三个维度进行不同的取舍和平衡。
等级 | 优化程度 |
---|---|
-O0 | 没有优化 |
-O1 | 对代码的分支,常量以及表达式等进行优化 |
-O2 | 寄存器级的优化以及指令级的优化 |
-O3 | 使用伪寄存器网络,普通函数的内联,以及针对循环的更多优化 |
1.2.4 -Wall
生成所有警告信息。
相反的:-w
不生成警告信息。
1.2.5 -g
添加 gdb 调试。生成的文件比没有调试的文件大很多。
2.静态库
2.1 命名规则
lib + 库的名字 + .a
例如:libmytest.a
2.2 制作步骤
制作静态库时,目录结构如下:
1 |
|
- 将 src 中的 .cc 文件编译生成对应的 .o 文件。(记得加
-c
参数)
1 |
|
- 将生成的 .o 文件打包成静态库
ar rcs 静态库的名字(libMytest.a) 生成的所有的 .o
1 |
|
PS: ar 命令 用于打包库,其中,
-r
:将文件插入备存文件中。
-c
:建立备存文件。
-s
:若备存文件中包含了对象模式,可利用此参数建立备存文件的符号表。
- 发布静态库
将静态库和头文件发布。
2.3 使用
使用时,目录结构如下:
1 |
|
**(1)方式一:**同时编译源文件和静态库。
同时制定头文件的目录。
1 |
|
**(2)方式二:**编译源文件时,使用 -L
和 -l
指定静态库。
-L
:表示静态库所在文件。
-l
:表示静态库的名称。
重点:需要掐头去尾。去掉 lib 和 .a。
1 |
|
2.4 查看静态库内部
1 |
|
显示内容如下(其实就是 .o 文件):
1 |
|
2.5 静态库原理
可执行文件中,会打包用到的 .o 文件。
在文件的虚拟地址空间中,静态库的位置是绝对位置,无论什么时候执行程序,静态库都是在 text 区。
2.6 优缺点
(1)优点:
- 发布程序时,不需要提供对应的库。
- 加载库的速度快。
(2)缺点:
- 库被打包到应用程序中,导致应用程序很大。
- 库发生改变,需要重新编译程序。
3.动态库/共享库
3.1 命名规则
lib + 名字 + .so
例如:libMytest.so
3.2 制作步骤
制作动态库时,目录结构如下:
1 |
|
- 生成与位置无关的 .o 文件
1 |
|
-fPIC
: 指明生成与位置无关的 .o 文件。
- 将 .o 打包成共享库(动态库)
g++ -shared 动态库的名字(libMytest.so) 生成的所有 .o 文件
1 |
|
-shared
:链接选项,使用动态库。
-o
:指定动态库的名字。
3.3 使用
使用时,目录结构如下:
1 |
|
**(1)方式一:**同时编译源文件和动态库。
同时制定头文件的目录。
1 |
|
**(2)方式二:**编译源文件时,使用 -L
和 -l
指定动态库。
-L
:表示动态库所在文件。
-l
:表示动态库的名称。
重点:需要掐头去尾。去掉 lib 和 .a。
1 |
|
3.4 动态库链接失败
(1)问题描述
用 方式二 编译生成的可执行文件,运行之后会报错:
1 |
|
(2)原因
a.out 执行时,没有找到动态库,使用 ldd 命令可查询:
1 |
|
3.5 解决方式
为了让 程序 找到动态库所在位置,有以下四种方式。
(1)方式一:动态库放在系统的库目录 /lib64 中(不允许使用)
1 |
|
(2)方式二:临时设置环境变量 LD_LIBRARY_PATH(临时测试用)
【终端关闭,配置失效】
1 |
|
(3)方式三:将环境变量 LD_LIBRARY_PATH 写入 .zshrc 中(不正规)
编辑完 .zshrc 需要【重启终端】
1 |
|
(4)方式四:修改 /etc/ld.so.conf
- 打开 /etc/ld.so.conf
- 将动态库的 绝对路径,写入 /etc/ld.so.conf
- 更新:ldconfig -v
3.6 动态库原理
动态库(共享库)的位置是相对位置(共享库段中哪里有空闲,就加载到哪里),静态库是绝对位置。a.out 执行后,动态连接器会自动调用使用的动态库。
3.7 优缺点
(1)优点
- 执行程序体积小。(静态库会将被打包到 a.out 中,而动态库不会。)
- 动态库更新,不需要重新编译。
(2)缺点
- 发布程序时,需要单独给用户提供动态库。
- 动态库没有被打包到应用程序中,所以加载速度会比静态库慢一些。
4.gdb
4.1 gdb调试
命令 | 含义 |
---|---|
gdb app | 进入gdb调试 |
start | 只执行第一步 |
n | next,下一步【将函数调用当成一步】 |
s | step,单步【进入到函数体内部】 |
finish | 从函数体内部跳出【需先删去函数体内的断点】 |
c | continue,直接停在断点位置 |
u | 退出当前循环 |
如果 finish 命令报错,出现以下提示,表示需要删除函数体内的断点。
1 |
|
4.2 查看代码
命令 | 含义 |
---|---|
l | list,【优先展现main函数】 |
l 10 | 当前文件第10行 |
l fun.cc:10 | fun.cc 的第10行 |
l fun.cc:My_fun | fun.cc 的 My_fun 函数 |
4.3 设置断点
命令 | 含义 |
---|---|
b 10 或 break 10 | 第十行打断点 |
b main 或 break main | main 函数处打断点 |
b fun.cc:My_fun | fun.cc 的 My_fun 函数处打断点 |
b 10 if i==10 | 第10行,i 等于 10 的时候才停 |
del 「断点的编号」 或d「断点的编号」 | 删除断电 |
info b 或 i b | 显示所有断点编号 |
4.4 查看变量
命令 | 含义 |
---|---|
p i 或 print i | 查看变量 i 的值 |
ptype i 或 ptype i | 查看变量 i 的类型 |
4.5 设置变量
set var 变量名 = 值,直接跳到该状态。
例子:在for循环中,想直接看到当 i=10 的时候的结果。
1 |
|
4.6 设置追踪变量
跟踪变量,就是在每一步执行的时候,会显示 被跟踪变量 的值。
命令 | 含义 |
---|---|
display 编号 | 跟踪变量 |
undisplay 编号 | 取消跟踪变量 |
info display | 查看追踪变量的编号 |
4.7 退出 gdb 调试
quit
5.makefile
5.1 命名
makefile 或者 Makefile
5.2 规则简单格式
**(1)三要素:**目标、依赖、命令
(2)基本格式:
1 |
|
1 |
|
5.3 多条规则
**问题:**如果有很多的文件需要编译,当修改其中一个文件时,为了避免重新编译所有文件,将文件分开编译。
**解决:**默认第一条的目标为终极目标,第一条规则的依赖如果不存在,就向下面的其他规则中查找。
1 |
|
graph TD
A(终极目标 app) -->|向下寻找依赖| B(main.o)
A -->|向下寻找依赖| C(sub.o)
A -->|向下寻找依赖| D(add.o)
A -->|向下寻找依赖| E(mul.o)
A -->|向下寻找依赖| F(div.o)
B -->|向下寻找依赖| G(main.cc)
C -->|向下寻找依赖| G1(sub.cc)
D -->|向下寻找依赖| G2(add.cc)
E -->|向下寻找依赖| G3(mul.cc)
F -->|向下寻找依赖| G4(div.cc)
5.4 原理
(1)执行流程图:
graph LR
A(生成目标) --> B(依赖条件)
B -->|存在| C(通过命令生成目标)
B -->|不存在| D(寻找新规则用来生成依赖条件)
D -->|"依赖条件作为子目标 -- 向下寻找"| A
(2)问题:makefile 如何知道哪些文件被更新?
当修改其中一个文件时,使用 makefile 可以避免重新编译所有文件,只编译更改过的文件。
makefile 如何知道哪些文件被修改了,需要重新编译?
(3)实现:
比较 依赖 和 目标 最后修改的时间。【依赖必须比目标的生成时间更早】
(4)更新流程图:
graph LR
A(更新目标) -->|检查| B(更新)
B -->|检查| C(依赖)
B -->|检查| D(依赖)
B -->|检查| E(依赖)
B -->|检查| F(新更改的依赖)
F -->|"依赖比目标新 -- 更新目标文件"| A
5.5 变量
(1)普通变量:
格式:obj=main.o add.o sub.o mul.o
使用:$(obj)
(2)makefile 的自动变量:
$<
:规则中的第一个依赖
$@
:规则中的目标
$^
:规则中的所有依赖
PS:自动变量只能在命令中使用
(3)实例:
1 |
|
g++ -c $< -o $@
表示 编译 第一个依赖(%.cc) 生成 目标(%.o)。
%.o:%.cc
:% 是匹配符号,main.o add.o sub.o mul.o 这些会自动匹配 %.o,并查找对应的 %.c 依赖。
(4)makefile 维护的变量:
makefile 中有一些自动维护的变量,均为大写。
- CC:gcc
- CPPFLAGS:预处理器需要的选项,如 -I
- CFLAGS:编译时使用的参数,如 -Wall -g -c
- LDFLAGS:链接库使用的选项,如 -L -l
用户可更改这些 makefile 维护的变量的默认值。
使用时,还是 $(CC),如:
1
2
3
4CC=g++
CPPFLAGS=-I
app:main.cc sub.cc mul.cc
$(CC) main.cc sub.cc mul.cc -o app -I include -L lib -l Mylib
5.6 函数
(1)痛点
在上面 5.5.(3) 的实例中,我们需要手动写 main.o add.o sub.o mul.o,如何避免写诸多的文件名?
(2)解决
使用函数,根据找到的 .cc 文件名,来自动生成 .o 的文件名。
(3)实例
主要解决变量 obj 后面要写太多文件名的问题。
1 |
|
src=$(wildcard ./*.cc)
:获取当前文件下的所有 .cc 文件。wildcard:n. 通配符。
obj=$(patsubst ./%.cc, ./%.o, $(src))
:将 src 中的所有 .cc 文件名替换成 .o 文件名,并传给 obj 做变量的值。patsubst:v.模仿。
(4)格式
以 obj=$(patsubst ./%.cc, ./%.o, $(src))
为例,obj 是返回值,patsubst 是函数名,./%.cc, ./%.o, $(src)
是参数,参数间用逗号分隔。
5.7 clean
(1)痛点
当每次重新编译时,都需要先删除可执行文件,有没有办法可以自动在编译之前,删除过时的可执行文件呢?
(2)解决
1 |
|
- 直接 make,完成是终极目标,也就是第一行的目标。
- 如果要执行 clean 这个目标,可以使用
make clean
命令单独执行 clean 目标。
(3)伪目标声明
clean 这种目标 不会生成任何新文件,称为 伪目标。
问题:当前目录下有一个叫 clean 的文件,如果我们执行
make clean
时,会报以下错误:1
2
3➜~ touch clean # 创建一个 clean 的同名文件。
➜~ make clean # 执行 make clean
make: “clean”是最新的。 # 报错信息原因:
因为完成目标的过程中,会检查文件是否是最新的文件,如果文件是最新文件,便不会执行命令,完成目标。
执行 clean 目标 不会生成任何新文件, 那么当前目录下的同名文件 clean 永远是最新的,故不会完成 clean 目标。
解决:加入 伪目标声明 即可解决问题。
1
2
3./PHONY:clean
clean:
rm -rf app
(4)忽略报错,继续执行
当完成目标时,命令中有些会报错,比如下面的命令,如何 命令1报错后,命令2继续执行呢?
1 |
|
只需要在命令1前加入 -
即可。
1 |
|
结果如下,会忽略报错:
1 |
|
5.8 其他目标
如同 clean 目标一样,我们还可以加入一些其他的自定义目标。
比如 hello 目标:
1 |
|
只要 make hello
即可执行 hello 目标,也就是执行 echo"welcome"
命令。
6.系统IO函数
内核提供的函数。
6.1 C库函数
C库函数执行流程(用户层面)如下,重点:
- FILE* 指针:一个结构体,主要变量为文件描述符(FD)、文件读写指针位置(FP_POS)、IO缓冲区(BUFFER)。
- 先在内存中的IO缓冲区操作,再写入磁盘文件中。
- 文件描述符通过 inode 找到对应的磁盘文件。
6.2虚拟地址空间
(1)大小
为什么操作系统为进程分配的虚拟地址空间是 4G?
因为 32 位系统中,232 是 4G 大小。
0G-3G 是用户区,用户可使用。
3G-4G 是 Linux 内核区,不允许用户区操作。
(2)ELF
Linux 可执行程序的文件格式是 ELF。
ELF 段 = test 段 + data 段 + bss 段。
每次执行都是从 main 函数位置开始执行,即 test 段。
**命令:**file
可查看文件的文件格式。
1 |
|
(3)受保护的地址
这个区域很小,用户不可操作,在 C 语言中,NULL 其实是宏定义,#define NULL (void*)0
,也就是说,空指针其实指的就是这个地址。
PS:在 C++ 中,由于不允许 void* 自动转换别的类型指针,所以 NULL 的宏定义是 #define NULL 0
,为了消除二义性(比如函数重载),推荐使用nullptr。
(4)虚拟地址空间作用
方便编译器和操作系统安排程序的地址分布。
程序可以使用 连续的虚拟地址空间 来访问物理内存中 不连续的内存缓冲区。
方便进程之间的隔离
不同进程使用的虚拟地址彼此隔离,一个进程中的代码无法修改正在由另外一个进程使用的物理内存。
方便操作系统使用稀缺的内存
程序可以是连续的虚拟地址空间,来访问比 可用物理内存 大的内存缓冲区。
当物理内存可用量变小时,内存管理会将物理内存页(通常大小为 4KB)保存到磁盘文件。数据或代码页会根据需要在内存和磁盘之间移动。
6.3 文件描述符表
- 在内核区中,有一个 PCB 进程控制块,(每一个进程都有自己的 PCB 进程控制块)。
- PCB 进程控制块有一个文件描述符表,文件描述符表本质上是一个数组,数组大小为 0-1023。文件描述度实际上是整型数。
- 每一个文件描述符对应着一个打开的文件。
- 第 0、1、2个默认打开的是标准输入(stdin)、标准输出(stdout)、标准错误(stderr)。
6.4 C库函数和系统函数的关系
(1)C库函数执行流程
以 printf 函数为例,C库函数 将文件描述符、文件指针位置、缓冲区等参数传给系统 API,
- 首先调用应用层的 系统函数 write,由 write 进行系统调用,
- 系统调用函数 sys_write 调用设备驱动,此时操作的空间由用户空间转换为内核空间,
- 最后设备驱动函数操作硬件(显示器)。
(2)效率比较
方案一:write 和 read
方案二:getc 和 putc
每次都是读一个字符,写一个字符。请问哪个方案快?
getc 和 putc 更快!
原因:C库函数会自己维护一个缓冲区,减少写入内核区中的缓冲区的次数。
write 和 read:用户区 —> 内核区的缓冲区 —> 硬盘。每读写一个字符,都需要从 用户区 拷贝到 内核区。
getc 和 putc:c 库函数缓冲区 —> 用户区 —> 内核区的缓冲区 —> 硬盘。C缓冲区读满了,再拷到 用户区,并一次性拷到 内核区。
6.5 man手册不全
举个例子:在 man 中查询 系统IO函数 open 的帮助,显示在第 2 节中没有关于 open 的手册页条目。
中文安装。
1 |
|
切换英文。
1 |
|
6.6 open
(1)介绍
man 2 open
2 表示帮助文档第二章节(系统调用,)
1 |
|
- flags:打开方式:O_RDONLY、O_WRONLY、O_RDWR
- mode:权限
- 可选性:O_CLOEXEC、O_CREAT、O_DIRECTORY、O_NOCTTY、O_NOFOLLOW、O_TRUNC、O_TTY_INI
- 返回值:返回一个新的文件描述符(file descriptor),或者返回 -1 并更改 errno 的值。
(2)errno 变量
- errno 是一个全局的变量,定义在 /usr/include/asm-generic/errno.h 文件(第1-34个错误定义)和 /usr/include/asm-generic/errno-base.h(第35-133个错误定义)中。
1 |
|
- 当调用系统IO函数出错时,该函数就重新设置 errno 的值。
(3)perror 函数
头文件:stdio.h
函数定义:void perror(const char* s)
函数作用:将 上一个函数发生错误的原因 输出到标准错误设备(stderr)
上一个函数发生错误的原因 根据全局变量 errno 的值来决定。
会先打印出参数 s 所指字符串,再打印错误原因字符串。
1
perror("发生错误的原因:");
(4)open 函数使用-O_RDWR
**需求:**打开已存在的文件 hello.cc
- 打开文件失败时,open 返回值(文件描述符)为 -1。
- O_RDWR:权限为读写
- 注意头文件
1 |
|
(5)open 函数使用-O_CREAT
**需求:**创建新文件
- 创建文件需三个参数,O_RDWR(可读写)、O_CREAT(创建)、0777(权限)
- 关于权限:虽然给定权限是 0777,但是文件实际权限是
给定权限 - 本地掩码
- 关于掩码: 命令
umask
可以查看本地掩码,umask 002
可以更改本地掩码为 002。 - 举个例子:给定权限是 0777,本地掩码是 022,实际权限就是:0777-022=0755
- 实际过程是 本地掩码取反 后,和 给定文件权限 按位与。
1 |
|
(6)open 函数使用-O_EXCL
**需求:**判断为文件是否存在
- O_CREAT 和 O_EXCL 同时使用。
- 在 创建 之前,如果文件存在,会更改 errno,可以用 perror 打印出来。
- 如果没有 O_EXCL,却有 O_CREAT,打开已存在的文件无提醒,并不更改已存在的文件内容。
1 |
|
(7)open 函数使用-O_TRUNC
**需求:**将文件截断为0
1 |
|
6.7 read
(1)介绍
读取的内容,写入 buf,count 表示 buf 的大小。
ssize_t
:是有符号的 int。
1 |
|
(2)返回值
- -1:读文件失败,并设置了 errno 的值,可以用 perror 打印。
- 0:文件读完(读到文件末尾、管道写端关闭、socket 对端关闭)
- >0:实际读取的字节数量
6.8 write
(1)介绍
将 buf 的内容写入 fd 所指的文件。count 表示 buf 的大小。
1 |
|
(2)注意
会覆盖原文件,但是不会清空。
6.9 lseek
(1)用处:
获取文件大小(同 fseek)
1
2int size = lseek(fd, 0, SEEK_END);
// 返回值 = 当前指针的位置移动文件指针(同 fseek)
拓展文件(fseek 没有的功能):用无意义字符填充,扩展文件大小。
比如:拓展成为空洞文件。多线程下载一个 1G 大小的文件时,在开始下载时,便会生成一个 1G 大小的空洞文件,这样每个线程就知道写入的分别是哪个位置。
**(3)注意:**实现拓展文件,需要使用 lseek 函数之后,进行一次写操作 write。
(4)介绍
1 |
|
- off_t 是 int。返回值为当前指针的位置。
- offset 是文件指针的偏移量。
- whence 可以有三个值:
- SEEK_SET:开始位置
- SEEK_CUR:当前位置
- SEEK_END:结尾位置
(5)拓展文件实例
1 |
|
(6)实例:复制文件
1 |
|
7.Linux 文件操作函数
7.1 stat 命令
打印信息节点(inode)内容
1 |
|
PS:索引节点 inode:其本质为结构体 struct stat,存储文件的属性信息。这些信息被称为元数据。
元数据:文件大小,设备标识符,用户组标识符,文件模式,扩展属性,文件读取或修改的时间戳,链接数量,指向储存该数据的磁盘区块的指针,文件分类。
注意:数据分为 元数据 和 数据本身。
7.2 stat 函数
(1)介绍
1 |
|
(2)权限属性
在 struct stat 结构体中,有一个变量为:mode_t st_mode;
表示文件的类型和存取的权限。
一共 2 Byte,16位。
1-4bit | 5-7bit | 8-10bit | 11-13bit | 14-16bit |
---|---|---|---|---|
文件类型 | 特殊权限位 | User | Group | Others |
**举个例子,**如果想拿到 User 的权限信息,就需要使用掩码 S_IRWXU 00700 做按位与操作。
00700 = 000 000 111 000 000
刚好拿到的是 User 权限所对应的 三位(读、写、执行)。
如果再想获得读的权限,需要使用掩码 S_IRGRP 00400 做按位与操作。
00400 = 000 000 100 000
刚好拿到 读权限 的那一位。
PS:其中文件类型和权限同理,需要 st_mode 先与 S_IFMT 0170000 做按位与操作,过滤 st_mode 中除文件类型以外的信息。判断与所对应宏是否相等。
比如:
- S_IFSOCK 0140000 套接字
- S_IFNK 0120000 符号链接
- S_IFREG 0100000 普通文件
- …
(3)实例:获取文件 size 属性
1 |
|
7.3 lstat 函数
stat 和 lstat 区别:
- stat:追踪函数,st_size 是软连接所指向的文件的大小。
- lstat:不追踪,st_size 是软连接文件的大小。
举个例子:
- ls -l、rm:不追踪命令
- vi:追踪命令
7.4 access 函数
(1)介绍
测试指针文件是否拥有某种权限。
1 |
|
pathname:文件名
mode:权限类别
- R_OK:是否拥有读权限
- W_OK:是否有些权限
- X_OK:是否有执行权限
- F_OK:测试一个文件是否存在
返回值:
- 0:所有检查的权限都通过了测试
- -1:有权限被禁止
(2)实例:测试是否拥有执行权限
1 |
|
7.5 chmod 函数
(1)介绍
1 |
|
mode_t mode:同
int open(const char *pathname, int flags, mode_t mode);
如果指定权限为 0777, 则不需要权限与取反后掩码进行按位与操作。
(2)实例:更改文件权限
1 |
|
(3)问题
在上面实例中,chmod(argv[1], 0777)
,0777 是写进程序的,如果想要改变,需要重新编译,可不可以通过参数传入?
可以,但要注意 参数是字符串,chmod 形参是八进制数。需要使用 strtol 函数进行转换,用法见 strtol 函数。
(4)实例:利用 strtol 函数为 chmod 函数传入权限
1 |
|
7.6 strtol 函数
(1)介绍
字符串转化为整型。
1 |
|
- const char *nptr:0777,待转换的字符串
- char **endptr:NULL,忽略
- int base:8,表示八进制
(2)实例:将 0777 转换为 int 数值
1 |
|
注意: cout << strtol("755", NULL, 10) << endl;
流程如下:
- 将字符串 “755” 转化为整型 755(八进制 OCT)。
- 将八进制 755 转换为十进制 493 打印。
7.7 chown 函数
(1)介绍
更改所有者。
1 |
|
uid_t owner
:所有者的 uid。gid_t group
:所属组的 gid。
(2)查看 uid 和 gid
- 以第 4 行为例,3 代表 uid, 4代表 gid。
1 |
|
(3)实例:更改所属信息
1 |
|
7.8 truncate 函数
专门做函数扩展的函数。
1 |
|
off_t length
:指定文件大小。- length > 源文件size,扩展成空洞文件。
- length < 源文件size,截断文件,后面的字符全部截断。
- 返回值:0 --> 成功,-1 --> 失败。
7.9 link 函数
创建一个硬连接。
1 |
|
7.10 symlink 函数
创建一个软连接。
1 |
|
7.11 readlink 函数
读出软连接,读到的内容为:软连接所指文件的路径。
1 |
|
char *buf
:传出参数,内容为软连接所指文件的路径。const char *path
:软连接的路径。
7.12 unlink 函数
(1)作用:
unlink 软连接:删除一个软链接。
unlink 普通文件:也就是 unlink 硬链接。硬链接计数减 1,当减为0,释放数据块和 inode。
制作临时文件
创建一个新的文件,并使用 unlink 释放该文件,文件就会被自动释放。(用于缓存)
(2)介绍:
1 |
|
(3)实例:创建缓存文件,并自动清理
1 |
|
7.13 rename 函数
修改文件命名。
1 |
|
8.目录操作函数
8.1 chdir 函数
将当前进程的路径改为 path。
1 |
|
- 返回值:成功 --> 0,失败 --> -1。
8.2 getcwd 函数
获取当前进程工作目录。类似于 pwd。
1 |
|
将当前进程所在路径写入 buf 当中。
8.3 mkdir 函数
创建目录。注意:目录需要有执行权限才能打开。
1 |
|
mode_t mode
:同 chmod
8.4 opendir 函数
打开目录。
1 |
|
- 返回值:
- DIR 结构指针,该结构是一个内部结构,保存所打开的目录信息,作用类似于 FILE 结构。
- 函数出错返回 NULL。
8.5 readdir 函数
读目录。
1 |
|
- 返回值:返回一条记录项 struct dirent,含有变量如下。
1 |
|
- 其中,d_type 的类型。
宏 | 文件类型 |
---|---|
DT_BLK | 块设备 |
DT_CHR | 字符设备 |
DT_DIR | 目录 |
DT_LNK | 软连接 |
DT_FIFO | 管道 |
DT_REG | 普通文件 |
DT_SOCK | 套接字 |
DT_UNKNOWN | 未知 |
8.6 closedir 函数
关闭目录。
8.7 递归读目录获取文件个数
1 |
|
8.8 dup 和 dup2
复制文件描述符。
1 |
|
dup 返回值:返回文件描述符中没有被占用的最小的文件描述符。
dup2 返回值:返回 newfd 的值。
情况分析:
- newfd 是一个被打开的文件描述符,在拷贝前先关掉 new。
- oldfd 和 newfd 是一个文件描述符。不会关闭 new,直接返回 old。
复制之后,两个文件描述符会指向同一个文件:
- 两个文件描述符指向一个文件,但是文件指针只有一个。举个例子:如果通过一个文件描述符写入内容,不人工移动文件指针;另一个文件描述符再写入的时候,因为文件指针在末尾,所以会追加写入。
8.9 fcntl 函数
(1)作用
复制现有的文件描述符
获取/设置文件描述符的标记
获取/设置异步IO所有权
获取/设置记录锁
【重要】获取/设置文件状态标记(可修改已经打开的文件权限)
比如:打开文件的时候:只读;修改文件的权限:追加 O_APPEND
(2)介绍
1 |
|
- 返回值:
- 获取状态失败 --> -1;
- 获取状态成功 --> 0;
(3)F_GETFL:获取文件状态
1 |
|
cmd 参数:
宏 | 含义 |
---|---|
O_RDONLY | 只读打开 |
O_WRONLY | 只写打开 |
O_RDWR | 读写打开 |
O_EXEC | 执行打开 |
O_SEARCH | 搜索打开目录 |
O_APPEND | 追加写(不会覆盖) |
O_NONBLOCK | 非阻塞模式 |
(4)F_SRTFL:更改文件状态
1 |
|
可更改的宏 | 含义 |
---|---|
O_APPEND | 追加写 |
O_NONBLOCK | 非阻塞模式 |
9.进程管理
9.1 程序与进程
- 程序:二进制文件,占用磁盘空间。
- 进程:启动的程序。
- 所有数据都在内存。
- 需要占用更多的系统资源:CPU、物理内存。
9.2 并行和并发
- 并行:两个或者多个事件在 同一时刻 发生。
- 并发:两个或者多个事件在 同一个时间间隔 发生。
例如:
【并行】一边听课一边记笔记:听 和 记 同一个时刻发生。
【并发】先听课再记笔记:在同一个时间段完成两个事件。
9.3 PCB
每个进程都有一个 虚拟内存空间,虚拟内存空间中有 内核区, 内核区中有一个 进程控制块(PCB)。
进程控制块(PCB):一个结构体 struct task_struct。
进程id:系统每个进程有唯一的id。C 语言中用 pid_t 类型表示,其实是一个非负整数。
进程的状态:就绪、运行(执行)、阻塞(挂起)、终止、初始(创建)。
进程切换时需要保存和恢复的寄存器。
描述虚拟地址空间的信息。(MMU 会将虚拟内存映射到物理内存)
描述控制终端的信息。
当前工作目录(Current Working Directory)。
umask掩码。
文件描述符表,包含很多指向file结构体的指针。
和信号相关的信息。
用户id和组id。
会话(Session)和进程组。
进程可以使用的资源上限(Resource Limit)。
1 |
|
9.4 进程状态
进程的状态:就绪、运行(执行)、阻塞(挂起)、终止、初始(创建)。
9.5 fork 函数
(1)作用
创建子进程。
(2)介绍
1 |
|
- 返回值:有两个返回值。当 fork 成功时,就会有两个相同的进程,也就意味着有两个 fork 函数,父子进程的 fork 函数返回值不一样。
- 父进程:成功返回子进程的 PID。
- 子进程:成功返回 0。
- 失败:父进程返回一个负值。
(3)关系
- 子进程是父进程的拷贝。
- 子进程和父进程的 用户区 数据相同,内核区 不同。(唯一的不同是:内核区中的PCB中的PID不一样)
(4)子进程执行的位置
从 fork 函数执行后的位置开始执行。以下列代码为例,子进程从第4行开始执行。
1 |
|
(5)父子进程的执行顺序
不一定,谁抢到 CPU,谁先执行。
可以在代码中添加 sleep(1)
来测试。(sleep 会使进程让出 CPU)
(6)shell 切换时刻提前
当父进程执行完成,shell 会切换到前台,此时子进程还没执行完,依旧会在打印内容。会出现以下情况:
1 |
|
(7)log 输出顺序不能代表执行顺序
可能一个进程先执行,并且先执行一大批代码后,sleep了,只是没有打印 log。
1 |
|
9.6 getpid 函数
获取当前进程的 id。
9.7 getppid 函数
获取父进程的 id。
ppid = 1的问题
当父进程执行结束时,子进程就会变成僵尸进程,僵尸进程的 ppid = 1。
为避免 ppid = 1 的情况,可以使用 sleep 函数使父进程让出 CPU。
9.8 循环创建子进程
(1)不阻止子进程 fork
如果循环三次,会创建8个进程。
23 = 8。
1 |
|
(2)阻止子进程 fork
父进程只会创建三个子进程。
1 |
|
(3)判断是第几个子进程
一定要 sleep 父进程,否则子进程就可能为僵尸进程,ppid = 1。
1 |
|
9.9 ps 和 kill
1 |
|
可以查看父进程PPID、进程组PGID、会话SID
注意:能查出结果,不一定存在进程。(可能是 grep 进程)
1 |
|
9.10 进程组和会话
进程组:多个进程
会话:多个进程组
1 |
|
9.11 进程间的数据共享
先讲结论:各进程的数据完全独立,读时共享,写时复制
(1)问题:变量独立,地址为什么相同
如下代码,通过修改 num 变量,发现父子进程的 num 变量是相互独立的,为什么 num 的地址值却相等?
1 |
|
(2)解决:因为虚拟内存空间
- 父子进程都有自己的一份虚拟地址空间,相互独立。
- &num 打印的是虚拟地址空间,而不是真正的物理内存。只代表在当前 虚拟地址空间 中的位置,因为 num 是在 fork 之前就初始化的,所以父子进程的 num 在 虚拟地址空间 中的位置相同。
- 虚拟内存空间,实际映射到一个物理内存上。
重点:读时共享、写时复制
- 对于 读操作,父子进程中的 num 会映射到物理内存中的同一个地方【好处:节省空间,提高效率】。
- 父进程发生 写操作,物理内存会拷贝一份 num 变量,供父进程写入。
- 子进程发生 写操作,物理内存会拷贝一份 num 变量,供子进程写入。
- PS:复制时,不会调用拷贝构造。猜测是物理层面上的拷贝。
9.12 execl 函数族
在进程中调用其他程序。
(1)问题
父子进程分别执行不同的操作,需要判断。如果把多个父子进程的业务逻辑全部写在一个文件中,不好控制。
(2)解决
**execl 函数族:**替换进程地址空间中的源代码(.txt段)。
(3)介绍
1 |
|
- path:需要执行的程序的绝对路径(建议)。
- 变参 arg:占位符 + 需要执行的程序所需的参数。
- 第一个 arg:占位符,随便写什么,一般写成执行程序的名字。
- 后面的 arg:需要执行的程序所需的参数。
- 参数列表最后加上 NULL。
- 返回值:失败返回 -1,但是使用不到。
- 成功:text 段被替换,没办法执行对返回值的判断。
- 失败:直接用 perror 打印保存信息即可。
PS:一般执行自己写的程序。
(4)实例
子进程调用 ls 命令。
注意: 子进程 execl 替换程序后,子进程不会执行之后的代码。
注意: 新程序不会开辟新的地址空间,直接使用子进程的地址空间。
1 |
|
(5)调用其他程序失败
如果执行成功不会 text 代码被替换,第 2 行代码不会执行。如果执行了判断,也意味着调用其他程序失败。
1 |
|
9.13 execlp 函数
和 execl 的区别在于,execlp 执行的是 PATH 环境变量中能够搜索到的程序。
(1)介绍
1 |
|
file: PATH 环境变量中能够搜索到的程序名称,不需要指定路径。
其他参数:同 execl。
返回值:同 execl。
注意:执行系统自带的程序(通过PATH变量查找)。如果一定执行自定义程序,必须绝对路径。
1 |
|
9.14 孤儿进程
(1)孤儿进程的始终
父进程 fork 子进程。
父进程结束,子进程还在。子进程就叫做孤儿进程。
孤儿进程被 init 进程管理,init 进程成为了孤儿进程的父进程。
(2)问题:为什么 init 进程要领养孤儿
- 进程结束后,才能释放用户空间。
- 但是无法释放内核区的 PCB。
- PCB 必须由父进程来释放。
(3)实例
- 让子进程 sleep,父进程执行结束后,子进程就变成了孤儿进程。
- 在父进程执行前后,打印子进程的 ppid,查看 ppid 的变化。
- 父进程结束前,子进程的 ppid 为父进程的 pid
- 父进程结束后,子进程的 ppid 为 1,表示 init 进程领养了孤儿进程,init 进程成为了 子进程的父进程。
1 |
|
9.15 僵尸进程
(1)僵尸进程的始终
- 父进程 fork 子进程。
- 子进程结束,父进程还在。
- 父进程不释放子进程的 PCB。子进程就叫做僵尸进程。
PS:僵尸进程是死掉的进程。kill 无法杀死僵尸进程。
(2)实例
- 让父进程一直在忙,无限循环打印 1。父进程没有机会释放子进程的PCB控制块。
- 子进程结束后,成为僵尸进程。
1 |
|
进程后面显示 <defunct>,并显示 STAT = Z+,即代表僵尸进程。
PS:zombie 表示僵尸,Z+ 中的 Z 就是 zombie 的缩写。
1 |
|
9.16 进程回收
(1)wait 阻塞函数
阻塞条件:子进程死亡。
1 |
|
- 返回值:
-1:回收失败,代表没有子进程了。
1
2
3
4// 可以利用 返回值 做循环条件,循环回收多个子进程。
while (wait(&status) != -1) {
// ...
}> 0:回收成功,返回子进程对应的 pid。
- status:判断子进程是如何死亡的。
- 正常退出。
- 被某个信号杀死。
- 调用一次只能回收一个子进程。
(2)实例
在父进程中调用 wait 函数,wait 会阻塞,直到子进程结束,wait 函数才会执行结束,父进程才能继续执行下面的代码。
wait 回收成功,会返回子进程的 pid,我们打印 wait 返回值,来判断是否回收成功。
1 |
|
(3)status 的使用
- 如果不关注子进程是如何死亡的,status 参数写 NULL。
status:判断子进程是如何退出的?步骤如下:
定义 status 变量。
wail 函数传入 &status。(status 是 wail 的传出参数)
利用宏来判断 status。
WIFEXITED(status):非 0,进程正常结束。
WEXITSTATUS(status):当 WIFEXITED 为真时,获取进程退出状态的参数。
比如 return -1,WEXITSTATUS(status) = -1。
WIFSIGNALED(status):非 0,进程异常终止。
- WTERMSIG(status):当 WIFSIGNALED 为真时,获取使进程中止的信号的编号。
- 段错误,会导致非正常退出,并且 进程中止的信号的编号 是 11(SIGSEGV)。
PS:助记:W IF EXITED,W EXIT STATUS,W IF SIGNALED,W TERM SIG。
代码:当正常退出时,获取退出状态的参数。
1 |
|
代码:让子进程 sleep 200s,手动 kill 子进程,观察父进程 wait 函数获取到的中止进程的信号的编号。
1 |
|
(4)waitpid 函数
作用同 wait 函数。
1 |
|
参数 pid_t pid:
- pid = -1,等待任何子进程,此时 waitpid 退化成 wait 函数。
- pid > 0,等待进程 ID == pid 的子进程。
- pid = 0,等待其组 ID 等于 调用进程的组 ID 的任意子进程。
- pid < -1,等待其组 ID 等于 pid 的绝对值的任意子进程(非本进程组的子进程)。
status:用法同 wait 函数。
options:设置为 WNOHANG,函数非阻塞,设置为 0,函数阻塞。
返回值:
- > 0:返回清理掉的子进程 ID。
- -1:无子进程。
- == 0:options 参数为 WNOHANG ,且子进程正在运行。
10.进程间通信
10.1使用文件通信
(1)步骤
- 父进程打开文件后,fork 子进程。
- 此时父子进程均指向同一个文件。
- 可以实现父写子读。
(2)实例
注意:先让 子进程 sleep 2s,保证父进程先执行完。
1 |
|
10.2 IPC
进程间通信(InterProcess Communication)
IPC 常用的4种方式:
文件
管道:简单
信号:系统开销小
共享映射区:有无血缘关系的进程间通信都可以
本地套接字:稳定
11.管道
匿名管道pipe:没有血缘关系的进程间通信。
我们说的管道大概率是匿名管道,在磁盘中没有对应的磁盘文件。
比如 Linux 命令中 “|” 是匿名管道。
11.1 概念
**本质:**Linux 内核缓冲区。(伪文件)
**伪文件:**不在磁盘上,不占用磁盘空间,是内核的一块缓冲区。
**进程间通信的原理:**父进程创建管道后,fork 子进程,父子进程的文件描述符都会指向 同一个管道的读写两端,从而实现通信。
11.2 特点
两部分:
- 读端、写端,对应两个文件描述符。
- 数据写端流入,读端流出。
该缓冲区,内核自动销毁。何时释放?
- 操作管道的进程被销毁之后,管道自动被释放。
管道默认是阻塞的。
- 读写操作都阻塞。(父子进程使用 pipe 通信,不需要 sleep)
11.3 内部实现方式
环形队列(循环队列)。
管道缓冲区大小:默认4K。
- 命令查看默认缓冲区大小:
1 |
|
- 函数查看默认缓冲区大小:
1 |
|
11.4 局限性
数据只能读取一次,不能重复读取。
管道:半双工,数据传输方向是单向的。读写端均阻塞。
- 单工:遥控器。
- 半双工:对讲机。
- 全双工:电话。
匿名管道:适用于有血缘关系的进程。
11.5 创建匿名管道
创建一个内核缓冲区。
1 |
|
- pipefd[2]:传出参数,传出两个文件描述符。
- pipefd[0]:读端
- pipefd[1]:写端
- 返回值:失败返回 -1;成功返回 0。
11.6 实例:父子进程使用管道
实现
ps aux | grep zsh
父进程执行 ps,子进程执行 grep。用 pipe 的读写端来替换 stdout、stdin。
- ps 执行的结果,写入 stdout,也就是管道的写端 fd[1];
- grep 从 stdin 获取数据,也就是管道的读端 fd[0]。
- 注意:父进程要关闭读端,子进程要关闭写端。因为数据在管道中是一次性的,防止自己读走自己写入的数据。
1 |
|
11.7实例:兄弟进程使用管道
实现
ps aux | grep zsh
与 父子进程使用管道 相比,子进程代替了父进程的操作,并需要将父进程的读端写端全部关闭。
1 |
|
11.8 管道的读写行为
- 读操作
- 有数据:正常读
- 无数据
- 写端全部关闭:read 解除阻塞,返回 0。
- 写端没有全部关闭:read 阻塞。
- 写操作
- 读端全部关闭:管道破裂,内核给当前进程发送信号:13(SIGPIPE),进程被终止。
- 读端没有全部关闭:
- 缓冲区写满了:停止写入,等待 read 读走数据。
- 缓冲区没满:正常写。
(9)设置非阻塞
默认读写两端都阻塞。半双工。
以 设置 读端非阻塞为例。
利用 fcntl 变参函数。功能:复制文件描述符、修改文件的 flags 属性(设置非阻塞使用该功能)。
步骤:
1 |
|
12.fifo
有名管道:没有血缘关系的进程间通信
12.1 概念
- 文件类型是 p,表示管道。
- 实际上也是伪文件(同 pipe),磁盘上的文件大小永远为 0。
- 在内核中有一个对应的缓冲区。
- 半双工(同 pipe)
- 有阻塞行为(同 pipe)
**使用场景:**没有血缘关系的进程间通信。
12.1 创建方式
- 命令
1 |
|
- 函数
1 |
|
12.3 使用及原理
多个进程,均打开同一个 fifo 文件,获得一个文件描述符,便可以实现读写通信。
写数据进程:
- 打开 fifo,只写
1 |
|
- 写入数据 buf
1 |
|
- 关闭文件
1 |
|
读数据进程:
- 打开 fifo,只读
1 |
|
- 读出数据到 buf
1 |
|
- 使用数据:比如把读到的数据写到标准输出文件
1 |
|
- 关闭文件
1 |
|
12.4 实例:两个程序通过 fifo 通信
fifo_write.cc:每 3 秒向 fifo 写入一次数据。并打印 I wrote!。
1 |
|
fifo_read.cc:fifo 中一有数据,就读到 buf 中,并输出到屏幕上。
1 |
|
13.mmap
创建内存映射区。
13.1 概念
mmap 可以将磁盘文件映射到内存中,并返回内存映射区的首地址 ptr,通过 ptr 可以读写内存映射区。
13.2 功能
- 功能一 – 修改磁盘文件
将磁盘中的文件,映射到虚拟内存中的动态库加载区,直接修改映射区数据即可。
**优点:**直接在内存中修改数据会更快。
**缺点:**没有阻塞
- 功能二 – 进程间通信
基于功能一。父进程在创建内存映射区后,再 fork 子进程,父子进程的内存映射区由同一个磁盘文件映射,便可实现通信。
13.3 函数原型
1 |
|
adrr:内存映射区的首地址,一般传入 NULL即可。
length:映射区的大小。
- 按 4k 的倍数增长。
- 一般指定磁盘文件的大小即可。
port:映射区权限
- PORT_READ:映射区必须有读权限
- PORT_WRITE
例:赋予读写权限时,port = PORT_READ | PORT_WRITE
flags:标志位参数
- MAP_SHARED:修改了内存数据会同步到磁盘文件。
- MAP_PRIVATE:修改了内存数据不会同步到磁盘文件。
fd:文件描述符
- 要映射的磁盘文件的文件描述符
- open 得到的
offdet:映射文件的偏移量
- 映射的时候,文件指针的偏移量
- 偏移量必须是 4k 的整数倍
返回值:
- 调用成功,返回映射区的首地址
- 调用失败:返回宏 MAP_FAILED,MAP_FAILED = -1。
13.4 实例:mmap 创建内存映射区读写磁盘文件
步骤:
- 打开文件
- 创建内存映射区
- 读写:写数据使用 strcpy,写入时会覆盖。
- 释放内存映射区
- 关闭文件。
1 |
|
13.5 问题
想获取内存映射区的第二个字符,可以让 ptr 进行 ++ 操作吗?
不可以。因为会导致
munmap(ptr, length)
失败。如果需要进行 ++ 操作,可以定义一个新的变量,让新变量完成 ++ 操作。
char *new_ptr = ptr
。open 文件时,指定的权限是 RDONLY,mmap 指定的权限是 PROT_READ | PROT_WRITE,会怎么样?
会报错:Permission denied(没有权限)。
打开文件是只读;创建内存缓冲区却是读写。打开文件的权限应该大于等于 mmap 设定的权限。
传入的 offdet 参数是 1000,会怎么样?
会把错:Invaild argument(参数无效),必须是 4k 的整数倍。
mmap什么情况会调用失败?
- 第二个参数 length = 0
- 第三个参数必须有 PORT_READ,且其权限必须小于等于 fd 的打开权限。
- offdet 必须是 4k 的整数倍。
可以 open 的时候,O_CREAT 一个新文件来创建映射区吗?
可以,但创建的新文件,没有大小,需要进行文件扩展。
- 方法一:使用 lseek之后,进行一次 write 操作。
- 方法二:使用 ftruncate(fd, length)。
mmap 创建内存映射区后,关闭文件描述符,会有什么影响?
对于读写均没有影响。
13.6 实例:有血缘关系的进程间通信
创建内存映射区后,fork 子进程,父子进程的内存映射区映射的是同一个文件。
1 |
|
13.7 实例:没有血缘关系的进程间通信
原理:write、read 进程均通过 mmap 对同一个文件,创建内存映射区,映射到真实的内存中其实是一块区域,从而实现通信。
write.cc:在内存映射区中,每隔一秒,去写入不同的 i。
1 |
|
read.cc:在内存映射区中,每隔一秒,打印内存缓冲区的数据。
1 |
|
13.8 munmap
释放内存映射区
1 |
|
- addr:mmap 的第二个参数,映射区的首地址。
- length:mmap 的第二个参数,映射区的长度。
- 返回值:失败 -1,成功 0。
14 信号
功能:杀死进程或者捕捉信号。通信尽量不用信号。
14.1 概念
大致过程:进程A --> 内核 --> 进程B
值得注意的是:信号的优先级比较高,进程B 收到信号后,会暂停正在处理的工作,优先处理信号,处理完信号再继续处理工作。
14.2 特点
- 简单
- 携带的信息量少
- 使用在某一个特定的场景下
14.3 信号的状态
- 产生
- 键盘:ctrl + c
- 命令:kill
- 系统函数:kill()
- 软条件:定时器
- 硬件:段错误、除 0 错误
- 未决:信号没有被进程处理
- 递达:信号被处理了
14.4 处理方式
忽略
捕捉
执行默认动作
- Term:终止进程
- Ign:忽略信号 (默认即时对该种信号忽略操作)
- Core:终止进程,生成Core文件(查验进程死亡原因,用于gdb调试)
- Stop:停止(暂停)进程;
- Cont:继续运行进程。
14.5 信号的四要素
四要素:信号名称、编号、动作、事件。
用 man 命令查看。
1 |
|
信号名称 | 编号 | 动作 | 事件 |
---|---|---|---|
SIGHUP | 1 | A | 在控制终端上是挂起信号, 或者控制进程结束 |
SIGINT | 2 | A | 从键盘输入的中断 |
SIGQUIT | 3 | C | 从键盘输入的退出 |
SIGILL | 4 | C | 无效硬件指令 |
SIGABRT | 6 | C | 非正常终止, 可能来自 abort(3) |
SIGFPE | 8 | C | 浮点运算例外 |
SIGKILL | 9 | AEF | 杀死进程信号 |
SIGSEGV | 11 | C | 无效的内存引用 |
SIGPIPE | 13 | A | 管道中止: 写入无人读取的管道 |
SIGALRM | 14 | A | 来自 alarm(2) 的超时信号 |
SIGTERM | 15 | A | 终止信号 |
SIGUSR1 | 30,10,16 | A | 用户定义的信号 1 |
SIGUSR2 | 31,12,17 | A | 用户定义的信号 2 |
SIGCHLD | 20,17,18 | B | 子进程结束或停止 |
SIGCONT | 19,18,25 | 继续停止的进程 | |
SIGSTOP | 17,19,23 | DEF | 停止进程 |
SIGTSTP | 18,20,24 | D | 终端上发出的停止信号 |
SIGTTIN | 21,21,26 | D | 后台进程试图从控制终端(tty)输入 |
SIGTTOU | 22,22,27 | D | 后台进程试图在控制终端(tty)输出 |
动作 | 说明 |
---|---|
A | 终止进程,Term |
B | 忽略这个信号,Ign |
C | 终止进程, 并且核心转储,Core |
D | 暂停进程,Stop |
E | 信号不能被捕获 |
F | 信号不能被忽略 |
PS:一个信号有多个值,是因为平台不一样,x86平台使用中间这列的值。
注意:SIGKILL 和 SIGSTOP 不能被捕捉(caught)、不能被阻塞(blocked)、不能被忽略(ignored)。
14.6 kill 函数
发信号给指定进程。
(1)函数原型
1 |
|
- pid:
- pid > 0:发送信号给指定的进程。
- pid == 0:发送信号给与调用 kill 函数进程属于同一进程组的所有进程。
- pid < -1:发送信号给 pgid == -pid 的进程组。
- pid == -1:发送给进程有权限发送的系统中所有的进程。
- 举个例子:Test 用户有权限发给 Test 用户的所有进程,但是没有权限发给 root 用户的进程。
- sig:可以使用数字或宏,推荐使用宏。
- 返回值:成功返回 0,出错返回 -1。
(2)查看有哪些信号
1 |
|
(3)实例:杀死父进程
子进程1秒后杀死父进程,父进程循环打印 i。
1 |
|
14.7 raise 函数
自己给自己发信号。
(1)函数原型
1 |
|
- 返回值:成功返回 0,出错返回 -1。
(2)实例
子进程给自己发送 SIGKILL,让父进程来回收子进程,并检测子进程因为什么信号而终止的。
1 |
|
14.8 abort 函数
给自己发送异常终止信号。
abort:v.流产 n.中止计划
(1)函数原型
1 |
|
- 没有返回值、没有参数
- 永远不会调用失败
(2)功能
给自己发送 SIGABRT 信号,终止进程,并产生 core 文件。
(3)实例
子进程调用 abort 函数,让父进程来回收子进程,并检测子进程因为什么信号而终止的。
1 |
|
14.9 alarm 函数
定时器超时,终止进程。
(1)函数原型
1 |
|
seconds:多少秒之后发送信号。
- 如果 seconds 被重置为 0,则意味着函数被取消了,不会终止进程。
返回值:上次倒计时还有剩余多少秒。
再次调用 alarm 会重置的 alarm 的倒计时数,上一次 alarm 函数会失效,会重新开始倒计时。【每个进程只有一个定时器】
1
2
3
4int a = alarm(5); // 第一次调用,返回值为 0。
sleep(2);
int a = alarm(10); // a = 3 // 因为倒计时5秒,sleep 2 秒,上一次倒计时还剩 3 秒
int b = alarm(4); // b = 10 // alarm(10) 和 alarm(4) 几乎同时运行,所以上一次倒计时还剩 10 秒
(2)功能
当倒计时结束时,函数会发出一个信号:SIGALRM,会终止进程。
1 |
|
(3)特点
每个进程只有一个定时器。
定时器使用的是 自然定时法。
不受进程状态影响。即使进程在进行复杂的算法或者卡顿,也不影响倒计时,互相独立。
(4)实例:测试计算机一秒钟能数多少数
1 |
|
使用 time ./a.out
可以查看程序运行时间,其实实际上数数的时间少于一秒,因为内核需要时间。
1 |
|
真实运行时间 = 用户 user + 内核 system + 损耗
真实运行时间 < 1s,原因:损耗来自于文件 IO 操作。
14.10 setitimer 函数
定时器,并实现周期性定时
(1)函数原型
1 |
|
- **which:**定时法则。
- ITIMER_REAL:自然定时法。发出的信号是 SIGALRM。
- ITIMER_VIRTUAL:虚拟的,只计算用户区代码运行的时间。发出的信号是 SIGVTALRM。
- ITIMER_PROF:用户 + 内核的时间。发出的信号是 SIGPROF。
- *const struct itimerval new_value:
- it_value:第一次响的时间。
- it_interval:从 it_value 开始,每隔 it_interval 这么长时间响一次。
- *itimerval old_value:传 NULL即可。这是一个传出参数,用来获取上一次定时器的信息。
(2)实例:使用 setitimer
1 |
|
14.11 阻塞信号集、未决信号集
(1)概念
阻塞信号集:要屏蔽的信号的集合。
未决信号集:没有被处理的信号的集合。
PS:阻塞信号集、未决信号集在 PCB 中,用户不能直接操作。
(2)信号集状态
- 每个信号对应一个标志性位。举个例子:
- 在阻塞信号集中,1 号标志位为 1,表示信号 SIGHUP(编号为 1)被设置阻塞;2 号标识位为 0,表示信号 SIGINT(编号为2)没有被设置阻塞。
- 在未决信号集中,3 号标志位为 1,表示信号 SIGQUIT(编号为3)没有被处理。
(3)关系
收到信号,先进入未决信号集中,如果阻塞信号集中设置了阻塞,则保持现状,不阻塞则执行。
**PS:**上图中的可执行的信号,也就是可递达的信号,可以分为三种:
- 执行默认动作
- 忽略
- 捕捉 --> 执行用户的回调函数
14.12 设置自定义信号集
用户不能直接更改阻塞信号集,我们需要设置一个自定义信号集,并通过自定义信号集来更改阻塞信号集。
当信号加入自定义信号集,表示该信号将会阻塞,即标志位为 1。(SIG_BLOCK情况下)
(1)将 set 集合全部信号置空
相当于说,该集合中所有信号标志位为 0。(SIG_BLOCK情况下)
**注意:**定义的信号集,初始值是随机的,需要用 sigemptyset 置空。
1 |
|
sigset_t:阻塞信号集、未决信号集、自定义信号集的类型。
sigset_t *set:集合的地址。
1
2sigset_t my_set; // 注意:定义的信号集,初始值是随机的,需要用 sigemptyset 置空。
sigemptyset(&myset); // 取地址
(2)将所有信号加入 set 集合
相当于说,该集合中所有信号标志位为 1。(SIG_BLOCK情况下)
1 |
|
(3)将 signo 信号加入到 set 集合
相当于说,signo 信号的标志位为 1。(SIG_BLOCK情况下)
1 |
|
- int signo:表示信号的编号,例如:SIGINT 编号为 2;SIGKILL 编号为 9。
(4)从 set 集合中移除 signo 信号
1 |
|
(5)判断信号是否存在
该函数可以判断阻塞信号集、未决信号集、自定义信号集。
1 |
|
- 返回值:存在返回 1,不存在返回 0。
14.13 设置阻塞信号集
将自定义信号集设置给阻塞信号集。
1 |
|
how:
SIG_BLOCK:
阻塞,自定义信号集中,有哪些信号,阻塞信号集中就会阻塞这些信号。
mask = mask | my_set
SIG_UNBLOCK:
解除阻塞,自定义信号集中,有哪些信号,阻塞信号集就会对这些信号解除阻塞。
mask = mask & (~set)
SIG_SETMASK:
覆盖阻塞信号集。
mask = set
sigset_t *oldset:传出参数,更改之前的阻塞信号集的状态。(不关心的话可以写 NULL)
实例:
1 |
|
14.14 读取当前进程的未决信号集
1 |
|
- sigset_t *set:传出参数,内核将未决信号集写入 set。
- 将传出参数 set 做 sigismember 的参数,即可知道信号是否在未决信号集中(即未被处理)。
实例:
1 |
|
14.15 信号捕捉 signal
给某一个进程的某一个特定信号(标号为 SIGINT)注册一个相应的处理函数,即对该信号的默认处理动作进行修改,修改为
handler
函数所指向的方式。
(1)函数原型
1 |
|
- signum:要捕捉的信号。
- sighandler_t handler:回调函数,自己实现。当捕捉到 signum,就会执行。
(2)实例:捕捉 crtl + c 信号 SIGINT
1 |
|
14.16 信号捕捉 sigaction
同 signal。
(1)函数原型
1 |
|
- signum:要捕捉的信号。
- struct sigaction *oldact:上一次捕捉时的设置,一般传 NULL。
在 const struct sigaction 中:
sa_handler:回调函数。
sa_mask:
- 在信号处理函数 sa_handler 执行过程中,临时屏蔽指定的信号。
- sa_handler 结束,取消临时屏蔽,会继续执行该信号。
- 没有特殊需求就使用清空操作。
sa_flags:如果使用 sa_handler,sa_flags 是 0;如果使用 sa_sigaction,sa_flags 是其他值。
(2)实例:捕捉 crtl + c 信号 SIGINT
1 |
|
14.17 内核实现信号捕捉的过程
【用户区】在执行主控制流程的某条指令时,因为中断、异常、系统调用而进入内核。
如果执行一些变量赋值、if 判断、循环语句不会进入内核区。
【内核区】内核处理完异常,准备回用户模式之前,先处理当前进程中可以可以递达的信号。
何为可递达?没有被阻塞的未决信号。见关系
【用户区】如果信号的处理动作是自定义的信号处理函数,则回到用户模式,执行信号处理函数。(而不是回到主控制流程)
【内核区】信号处理函数返回时,会执行特殊的系统调用 sigreturn,再次进入内核。
为什么需要再次进入内核,因为内核调用的信号处理函数,函数执行完之后需要返回。
【用户区】返回用户模式,从主控制流程中,上次中断的地方继续向下执行。
14.18 慢速系统调用中断
系统调用可以分成两类:
- 慢速系统调用:可能会使进程永远阻塞的一类。如果在阻塞期间收到一个信号,该系统调用被中断,不再继续执行。中断后返回 -1,设置 errno 为 EINTR(表示:信号中断)
- 其他系统调用。
15.守护进程
15.1 特点
- 后台服务进程
- 独立与控制终端
- 周期性执行某任务
- 不受用户登陆注销的影响
- 一般采用以 d 结尾的名字(服务)
15.2 进程组
- 进程组的组长:组里面的第一个进程。
- 进程组的 ID:和进程组组长的 ID 相同。
15.3 会话
多个进程组组成会话。
(1)创建会话
先fork,父进程死,儿子执行创建会话操作(setsid)
(2)注意事项
不能由进程组组长来创建会话。
创建会话的进程会成为新的进程组组长。
创建出的新会话会丢弃原有的控制终端。
PS:创建会话后,子进程便是守护进程,不受用户登陆注销的影响。
(3)实例
1 |
|
15.4 创建守护进程
fork 子进程,父进程退出
子进程创建新会话
- setsid()
改变当前工作目录(不必须,增强程序健壮性)
避免当前工作目录被卸载。
举个例子:插一个 U 盘,a.out,在 U 盘目录中启动 a.out。a.out 启动过程中,U 盘拔掉了。
重设文件掩码(不必须,增加子进程的灵活性)
- 子进程会继承父进程的掩码。
- umask(0):没有限制,0 取反就是 777。
关闭文件描述符
- close(0)
- close(1)
- close(2)
- 因为不需要终端,释放资源。
执行核心操作
实例:创建守护进程
创建会话,设置定时器,并捕捉该信号,调用回调函数,将时间写入文件。
1 |
|
16.线程
16.1 特点
创建线程后,地址空间没有变化,子线程和主线程共用地址空间。
进程退出成了线程(主线程)。
主线程和子线程有各自独立的 PCB。
子线程的 PCB 是从主线程拷贝过来的。
16.2 共享与通信
(1)共享:
- 环境变量
- 命令行参数
- 动态库
- 堆
- bss
- data
- text
**(2)不共享:**栈
- 举个例子:一共有 5 个线程,栈区被平均分为 5 份。
- 保证每个线程都有自己独立的栈空间,栈区变量不会冲突。
**(3)通信:**全局变量、堆。
16.3 Linux 和 Windows
Linux 和 windows 的实现原理不一样。
最开始,Linux 中没有线程,后由进程更改而来的。
在 Linux 中:
- 线程就是进程,轻量级进程。
- 对于内核,线程就是进程。只认 PCB(进程控制块)。
16.4 查看线程的 LWP 号
线程号(LWP号)和线程 ID是有区别的。
线程号(LWP号)是给内核看的。
线程 ID 是给用户看的。
如何查看?
- 找到进程 ID。
pd -LF 进程ID
即可以查看 LWP号。
16.5 多线程和多进程的区别
(1)多进程始终共享的资源:
- 代码
- 文件描述符
- 内存映射区
(2)多线程始终共享的资源:
- 堆
- 全局变量
(3)使用线程的好处:
- 节省资源(线程共用虚拟地址空间)
- 不会降低程序效率(CPU 按照 PCB 来分配时间片)
16.6 创建线程
(1)函数原型
1 |
|
- 返回值:成功返回 0,失败返回错误号。(线程库里不能使用 perror 打印。)
- *const pthread_attr_t arr:传出参数:无符号长整型。(一般七八位)
- *const pthread_attr_t arr:线程属性
- 传 NULL,默认父子线程不分离。
- 重要的属性——线程分离:子线程需要父线程去回收,线程分离之后,子线程就可以自己回收自己的线程。
- void *(start_routine)(void):函数指针。函数的作用:创建的子线程需要执行的函数;如果不指定,就不执行任何操作。
- *void arg:线程处理函数的参数。
(2)实例:创建线程
- 编译的时候,要用 ``-l` 指定 pthread 库。pthreath 不是 linux 系统库,多线程程序要依赖于 libpthread.so。
1 |
|
父子线程共用地址空间,一旦父线程,执行完毕,销毁地址空间,子线程就有可能来不及执行。
所以需要在程序结束前,让父线程 sleep 一下,让子线程抢到 CPU。
1 |
|
(3)实例:循环创建多个线程
用线程数组来储存创建线程的 ID。
并将 i 的地址当作参数传入,在线程处理函数中打印。
1 |
|
(4)重要问题:
- 打印结果:
1 |
|
问题:num 并没有按照传入的参数改变。
原因:线程处理函数执行到一半的时候,CPU 资源被抢走,等到有 CPU 资源的时候,线程去 (int *)arg 地址中取值;此时,i 的值已经在主进程中发生了改变。
16.7 线程打印错误信息
线程不可以直接打印错误信息,需要拿到错误号,再来获取错误信息。
1 |
|
实例:线程打印错误信息
1 |
|
16.8 pthread_exit 函数
当前线程退出,但是不影响其他线程的执行。
(1)与 exit 的区别
任何线程,只要调用 exit,整个进程都会结束。
而 pthread_exit 只会结束线程,不会回收地址空间。
(2)函数原型
1 |
|
- *void retval:
- (传出参数)一个指针,指向一个地址。可以将一些数据传出。
- 不可以使用栈地址,线程结束,该线程的栈地址就没了。(只能使用全局、堆)
- 如果没什么要传出的,可以使用 NULL。
1 |
|
16.9 阻塞等待子线程的退出
和回收进程的 wait 相似。
子线程的 PCB 需要父线程来回收,pthread_join 可以阻塞等待子线程结束。
(1)函数原型
1 |
|
- thread:要回收的子线程的线程 ID。
- retval:
- 传出参数,需要定义二级指针,并传入地址。
- 二级指针,读取线程退出的时候携带的状态信息。
- retval 指向内存和 pthread_exit 参数指向同一块内存地址。
(2)实例:父线程回收子线程
子线程睡眠,父进程等待。
1 |
|
16.10 线程分离
(1)pthread_detach 函数
创建线程后,实现线程分离。
1 |
|
- 调用 pthread_deach 不需要 pthread_join。
- 子线程会自动回收自己的 pcb。
(2)创建的时候设置线程分离属性
步骤:
- 定义线程属性类型
1 |
|
- 对线程属性变量初始化
1 |
|
- 设置线程分离属性
1 |
|
- attr:传出参数,该函数将 detachstate 写入 attr (线程属性)中。
- detachstate:
- PTHREAD_CREATE_DETACHED:分离
- PTHREAD_CREATE_JOINABLE:非分离
- 释放线程资源
1 |
|
16.11 取消线程
1 |
|
- 在要杀死的子进程的对应处理函数的内部,发生过系统调用的地方叫取消点。
- 有取消点,才可以调用 pthread_cancel。
- 补充:什么叫系统调用?write、printf 这些最后均会调用系统函数。
PS:如果只是一些定义和赋值,可以在函数内部,写上 pthread_testcancel 函数。
16.12 判断两个线程相等
因为 pthread_id 是长整型,可以直接比较,这个函数暂且用不上。
1 |
|
17.线程同步
17.1 为什么需要同步
数据的流向是 内存/缓存 – 寄存器 – CPU。也就是说,num变量进行计算需要三步:
- 从内存/缓存中拷贝到寄存器
- CPU 在寄存器中运算
- 从寄存器拷贝回内存/缓存。
如果实际线程运行时,谁抢到 CPU 谁来执行,所以运行顺序不一定,可能会出现线程指令交叉执行的情况。
如下图,其实完成了两次 num++,但是最后两次复制指令一起执行,覆盖了数据。
代码如下:两个线程分别让 num 自增一千万次,如果最后 num 不等于两千万,则说明线程不同步产生了问题。
PS:应保证大数据量,并多次运行,才更有可能出现问题。
1 |
|
17.2 线程同步的思想
(1)数据错乱的原因:
- 操作了共享资源。
- CPU 调度问题。
(2)什么是同步:
- 协同步调,按照先后顺序执行操作。
(3)思想
使用临界资源之前,先检查锁。
使用临界资源时,上锁。
使用临界资源时,解锁。
18.互斥锁(互斥量)
18.1 互斥锁的类型
类型:pthread_mutex_t
18.2 互斥锁的特点
- 多线程串行操作:缺点:慢/效率低。
18.3 加锁解锁的原理
mutex 初始化后,可以看作 mutex = 1,表示可以加锁,也就是可以继续往下执行。
加锁后,mutex == 0,表示当前不可加锁,需在此阻塞。
反之亦然,当解锁时,metux 从 0 变为 1。
线程 1:
1 |
|
线程 2:
1 |
|
过程分析:
当线程 1 拥有 CPU,开始执行,执行到 加锁语句 1
,检查 mutex 等于几。
- 如果 mutex == 1,继续执行
读写共享资源
,同时让 mutex = 0。 - 如果 mutex == 0,阻塞在此,等待 mutex == 1 时,再执行下面的代码。
当线程 2 抢到 CPU,开始执行,执行到 加锁语句 2
,检查 mutex 等于几。
发现 mutex == 0,阻塞在此,放弃 CPU。
18.4 步骤
- 创建互斥锁
1 |
|
- 初始化互斥锁
1 |
|
- restrict:表示 mutex 所指向的内存空间,只能由 mutex 来访问。其他的指针就是等于 mutex,也不能访问。(对函数使用没有影响)
- attr:线程锁的属性,填 NULL 即可。
- 初始化后,mutex == 1,加锁后,mutex == 0。一直在 0 和 1之前徘徊。【互斥锁的原理】
- 加锁
如果加锁成功,mutex 从 1 变为 0。
1 |
|
- 临界资源没被上锁 — 当前线程上锁。
- 临界资源已经上锁 — 当前线程阻塞,锁被打开的时候,线程解除阻塞。
1 |
|
- 临界资源没被上锁 — 当前线程上锁,返回 0。
- 临界资源已经上锁 — 加锁失败,返回错误号,不阻塞。(通过对返回值的判断,来决定执行什么操作)
- 解锁
在解锁的同时,会唤醒阻塞在该锁上的所有线程。mutex 从 0 变为 1。
1 |
|
- 销毁互斥锁/释放资源
1 |
|
18.5 临界区
临界区就是,锁所包含的代码块。
临界区越小越好,否则大段代码块串行,程序效率低。
举个例子:有 30 行代码,中间 20 行没有用到临界资源。应该多次加锁解锁。
1
2
3
4
5
6
7
8
9// lock
10 行需要加锁的代码
// unlock
20 行不需要加锁的代码
// lock
10 行需要加锁的代码
// unlock
18.6 实例
竞争资源:num。
两个线程分别对 num 进行 ++ 操作。
注意:
- C++11 中有 mutex 类,在头文件 <mutex> 中,是 std 命名空间下。
- 下列代码使用的是 pthread 中的互斥锁,为避免歧义报错,不可使用
using namespace std
。
1 |
|
19.死锁
19.1 造成死锁的原因
- 自己锁自己
1 |
|
- 加锁后未解开,其他线程阻塞
1 |
|
- 竞争临界资源
graph LR
1(线程1) -->|给 A 加锁| A(共享资源A)
1 -->|阻塞等待B| B(共享资源B)
2(线程2) -->|阻塞等待A| A
2 -->|给 B 加锁| B
19.2 解决死锁
让线程按照一定的顺序去访问共享资源。(比如先访问共享资源 A,再访问共享资源 B)
在访问其他锁的时候,需要先讲自己的锁解开。
trylock 不阻塞。
20.读写锁
读锁写锁是一把锁,调用函数不一样。
20.1 读写锁的类型
类型:pthread_rwlock_t
20.2 读写锁的特点
读共享,并行处理。
例:线程A读操作加锁时;线程B想要读操作加锁,加锁成功。
写独占,串行处理。
例:线程A写操作加锁时;线程B想要写操作加锁,加锁阻塞。
读写不能同时。
例:线程A读操作加锁时;线程B想要写操作加锁,加锁阻塞;随后线程C想要读操作加锁,加锁阻塞。
- 原因1:读写不能同时。线程A读加锁,线程B写加锁,会阻塞。
- 原因2:写的优先级更高。线程B写加锁阻塞,线程C再想读,也会阻塞。
- 等到线程A解锁,线程B会加锁成功,线程C依旧阻塞。
写的优先级更高。
例:线程A写操作加锁时;线程B想要读操作加锁,加锁阻塞;随后线程C想要写操作加锁,加锁阻塞。
- 等到线程A解锁,线程C会写加锁成功,线程B依旧阻塞。因为写的优先级更高。
20.3 读写锁适用的场景
读操作的次数 > 写操作的 次数
20.4 步骤
- 创建读写锁
1 |
|
- 初始化读写锁
1 |
|
- restrict:表示 rwlock 所指向的内存空间,只能由 rwlock 来访问。其他的指针就是等于 rwlock,也不能访问。(对函数使用没有影响)
- attr:线程锁的属性,填 NULL 即可。
- 加读锁
1 |
|
- 临界资源无写锁 — 当前线程上锁。
- 临界资源有写锁 — 当前线程阻塞,锁被打开的时候,线程解除阻塞。
1 |
|
- 临界资源无写锁 — 当前线程上锁,返回 0。
- 临界资源有写锁 — 加锁失败,返回错误号,不阻塞。(通过对返回值的判断,来决定执行什么操作)
- 加写锁
1 |
|
- 临界资源无锁 — 当前线程上锁。
- 临界资源有读锁或者写锁 — 当前线程阻塞,锁被打开的时候,线程解除阻塞。
1 |
|
- 临界资源无写锁 — 当前线程上锁,返回 0。
- 临界资源有写锁 — 加锁失败,返回错误号,不阻塞。(通过对返回值的判断,来决定执行什么操作)
- 解锁
1 |
|
- 销毁读写锁锁/释放资源
1 |
|
20.5 实例
竞争资源:number。
3 个写线程,5 个读线程对 number 进行读写操作。
1 |
|
##21.条件变量
不是锁,在不满足条件时,阻塞线程。
21.1 为什么需要条件变量
因为互斥锁没办法做到阻塞线程,它只能保证共享资源操作的原子性。
条件变量 + 互斥锁一起使用:
- 条件变量:不满足条件则阻塞
- 互斥锁:保护共享资源
21.2 条件变量的两个动作
- 条件不满足,阻塞线程。
- 条件满足,通知阻塞的线程开始工作。
21.3 条件变量的类型
condtion:条件
类型:pthread_cond_t。
21.4 步骤
- 创建条件变量
1 |
|
- 初始化条件变量
1 |
|
- attr:条件变量的属性,填 NULL 即可。
- 阻塞等待一个条件变量
1 |
|
- 功能:
- 阻塞线程
- 将已经上锁的 mutex 解锁(具体原因,参见 消费者生产者模型的问题 )
- 条件变量阻塞结束后,将 mutex 重新加锁
- 限时等待一个条件变量
非永久阻塞,阻塞一定的时长。
1 |
|
- abstime:阻塞时长,绝对时间。
- 唤醒 至少一个阻塞在条件变量上的 线程
1 |
|
- 唤醒 全部阻塞在条件变量上的 线程
1 |
|
- 销毁条件变量/释放资源
1 |
|
21.5 生产者消费者思路
生产者线程
1 |
|
消费者线程
1 |
|
重要问题:
当消费者发现 buffer 为空时,会阻塞等待,生产者生产。
问题在于:此时 buffer 已经被消费者加锁了,生产者会阻塞在
将产品加入缓冲区
的位置。
不用担心,因为加锁 buffer 后,条件变量发生了阻塞,此时会解开互斥锁;等到条件变量不再阻塞时,再锁上互斥锁。
21.6 实例:生产者消费者模型
利用 互斥锁 + 条件变量 实现生产者消费者模型。
- 条件变量:不满足条件则阻塞
- 互斥锁:保护共享资源
1 |
|
22.信号量
22.1 概念
头文件:<semaphore.h>
类型:sem_t
作用:加强版互斥锁
区别:
- 因为 mutex 在 0 和 1 之前徘徊,它实现的同步都是串行的。
- 信号量类似于封装了多把互斥锁,从而实现并行。
举个例子:
- 使用互斥锁:一次只能让一辆车进入停车场。
- 使用信号量:一次可以让四辆车进入停车场。
22.2 步骤
- 创建信号量
1 |
|
- 初始化信号量
1 |
|
- pshared:pshared == 0,线程同步;pshared == 1,进程同步。
- value:最多允许 value 个线程操作数据。
- 加锁
1 |
|
调用一次 wait,相当于对 sem 做了
--
操作。当 sem 减为 0时,线程会阻塞。
- 尝试加锁
1 |
|
- sem == 0,加锁失败,但是不阻塞,直接返回错误号。
- 限时尝试加锁
在一定时间内,不断尝试加锁,时间结束,不再尝试。
1 |
|
- 解锁
1 |
|
- 对 sem 做
++
操作。
- 销毁信号量/释放资源
1 |
|
22.3 生产者消费者思路
生产者线程
1 |
|
消费者线程
1 |
|
22.4 实例:生产者消费者模型
1 |
|
22.5有个问题
用信号量来实现消费者生产者模型时,
customer_sem 设为 0、produce_sem 设为 2,
简化来看,缓冲区是全局变量 num = 0,消费者做 – 操作,生产者做 ++ 操作。
当生产者有一个产品时,num = 1,customer_sem = 1,produce_sem = 1。
此时,消费者可以做 – 操作,生产者也可以做 ++ 操作,
请问如何保持对竞争资源 num 的互斥?是 信号量内部自己实现的互斥吗?