学习笔记|Linux系统

本文最后更新于:9 个月前

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
2
3
4
5
.
├── a.out
├── hello.cc
└── my_lib
└── my.h
  • 解决方案一:更改所写头文件的路径。

include "my.h" 改为 include "./my_lib/my.h"

  • 解决方案二:加入 -I 参数进行编译。

-I 参数指明调用头文件所在目录。(只用指明目录,无需写出完整路径)

1
g++ hello.cc -I ./my_lib/

1.2.2 -D

指定宏定义。

在 多个cpp文件 中加入调试信息时,为了控制是否打印调试信息,可以使用宏定义来控制。

1
2
3
4
// 在文件中这样写入调试信息。
#ifdef DEBUG
cout << "please print info." << endl;
#endif

编译时,通过控制 -D 参数来控制宏定义。

1
2
// 相当于定义了 DEBUG 宏 #define DEBUG
g++ hello.cc -D DEBUG

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
2
3
4
5
6
7
8
9
.
├── include # 头文件的目录
│   └── head.h # 声明了 add、sub、mul、div 等函数
├── lib # 打包好的静态库放在这里
└── src # 源代码的目录
├── add.cc # add 函数的实现
├── sub.cc # sub 函数的实现
├── mul.cc # mul 函数的实现
└── div.cc # div 函数的实现
  1. 将 src 中的 .cc 文件编译生成对应的 .o 文件。(记得加 -c 参数)
1
g++ *.cc -I ../include -c
  1. 将生成的 .o 文件打包成静态库

ar rcs 静态库的名字(libMytest.a) 生成的所有的 .o

1
ar rcs libMytest.a *.o

PS: ar 命令 用于打包库,其中,

-r:将文件插入备存文件中。

-c :建立备存文件。

-s:若备存文件中包含了对象模式,可利用此参数建立备存文件的符号表。

  1. 发布静态库

将静态库和头文件发布。

2.3 使用

使用时,目录结构如下:

1
2
3
4
5
6
.
├── include # 头文件所在目录
│   └── head.h
├── lib # 静态库所在目录
│   └── libMytest.a
└── main.cc # 使用了静态库的源文件

(1)方式一:同时编译源文件和静态库。

同时制定头文件的目录。

1
g++ main.cc ./lib/libMytest.a -I ./include

(2)方式二:编译源文件时,使用 -L-l 指定静态库。

-L :表示静态库所在文件。

-l:表示静态库的名称。

重点:需要掐头去尾。去掉 lib 和 .a。

1
g++ main.cc -I ./include -L lib -l Mytest

2.4 查看静态库内部

1
nm libMytest.a

显示内容如下(其实就是 .o 文件):

1
2
3
4
5
6
7
8
9
10
11
add.o:
0000000000000000 T _Z3addii

sub.o:
0000000000000000 T _Z3subii

mul.o:
0000000000000000 T _Z3mulii

div.o:
0000000000000000 T _Z3divii

2.5 静态库原理

可执行文件中,会打包用到的 .o 文件。

在文件的虚拟地址空间中,静态库的位置是绝对位置,无论什么时候执行程序,静态库都是在 text 区。

2.6 优缺点

(1)优点:

  • 发布程序时,不需要提供对应的库。
  • 加载库的速度快。

(2)缺点:

  • 库被打包到应用程序中,导致应用程序很大。
  • 库发生改变,需要重新编译程序。

3.动态库/共享库

3.1 命名规则

lib + 名字 + .so

例如:libMytest.so

3.2 制作步骤

制作动态库时,目录结构如下:

1
2
3
4
5
6
7
8
9
.
├── include # 头文件的目录
│   └── head.h # 声明了 add、sub、mul、div 等函数
├── lib # 打包好的动态库放在这里
└── src # 源代码的目录
├── add.cc # add 函数的实现
├── sub.cc # sub 函数的实现
├── mul.cc # mul 函数的实现
└── div.cc # div 函数的实现
  1. 生成与位置无关的 .o 文件
1
g++ -fPIC -c *.cc -I ../include

-fPIC: 指明生成与位置无关的 .o 文件。

  1. 将 .o 打包成共享库(动态库)

g++ -shared 动态库的名字(libMytest.so) 生成的所有 .o 文件

1
g++ -shared -o libMytest.so *.o -I ../include

-shared:链接选项,使用动态库。

-o:指定动态库的名字。

3.3 使用

使用时,目录结构如下:

1
2
3
4
5
6
.
├── include # 头文件所在目录
│   └── head.h
├── lib # 静态库所在目录
│   └── libMytest.a
└── main.cc # 使用了静态库的源文件

(1)方式一:同时编译源文件和动态库。

同时制定头文件的目录。

1
g++ main.cc ./lib/libMytest.so -I ./include

(2)方式二:编译源文件时,使用 -L-l 指定动态库。

-L :表示动态库所在文件。

-l:表示动态库的名称。

重点:需要掐头去尾。去掉 lib 和 .a。

1
g++ main.cc -I ./include -L lib -l Mytest

3.4 动态库链接失败

(1)问题描述

用 方式二 编译生成的可执行文件,运行之后会报错:

1
./a.out: error while loading shared libraries: libMytest.so: cannot open shared object file: No such file or directory

(2)原因

a.out 执行时,没有找到动态库,使用 ldd 命令可查询:

1
2
3
4
5
6
7
8
9
ldd a.out

# linux-vdso.so.1 => (0x00007ffe4534f000)
# libMytest.so => not found ## 没找到 libMytest.so
# libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fdd2b8b6000) ## libgcc的扩展
# libm.so.6 => /lib64/libm.so.6 (0x00007fdd2b5b4000)
# libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fdd2b39e000) ## GCC的组件
# libc.so.6 => /lib64/libc.so.6 (0x00007fdd2afd0000) ## 标准C库
# /lib64/ld-linux-x86-64.so.2 (0x00007fdd2bbbe000) ## 动态链接器

3.5 解决方式

为了让 程序 找到动态库所在位置,有以下四种方式。

(1)方式一:动态库放在系统的库目录 /lib64 中(不允许使用)

1
mv ./lib/libMytest.so /lib64/

(2)方式二:临时设置环境变量 LD_LIBRARY_PATH(临时测试用)

【终端关闭,配置失效】

1
export LD_LIBRARY_PATH=./lib		# 等号两侧不许有空格

(3)方式三:将环境变量 LD_LIBRARY_PATH 写入 .zshrc 中(不正规)

编辑完 .zshrc 需要【重启终端】

1
2
3
vim ~/.zshrc
# 添加以下键值对
export LD_LIBRARY_PATH=./lib

(4)方式四:修改 /etc/ld.so.conf

  1. 打开 /etc/ld.so.conf
  2. 将动态库的 绝对路径,写入 /etc/ld.so.conf
  3. 更新:ldconfig -v

3.6 动态库原理

动态库(共享库)的位置是相对位置(共享库段中哪里有空闲,就加载到哪里),静态库是绝对位置。a.out 执行后,动态连接器会自动调用使用的动态库。

3.7 优缺点

(1)优点

  • 执行程序体积小。(静态库会将被打包到 a.out 中,而动态库不会。)
  • 动态库更新,不需要重新编译。

(2)缺点

  • 发布程序时,需要单独给用户提供动态库。
  • 动态库没有被打包到应用程序中,所以加载速度会比静态库慢一些。

4.gdb

4.1 gdb调试

命令含义
gdb app进入gdb调试
start只执行第一步
nnext,下一步【将函数调用当成一步】
sstep,单步【进入到函数体内部】
finish从函数体内部跳出【需先删去函数体内的断点】
ccontinue,直接停在断点位置
u退出当前循环

如果 finish 命令报错,出现以下提示,表示需要删除函数体内的断点。

1
2
3
4
5
(gdb) finish 
Run till exit from #0 select_sort (arr=0x7fffffffe300, len=10) at main.cc:26

Breakpoint 2, select_sort (arr=0x7fffffffe300, len=10) at main.cc:23
23 if (arr[k] > arr[j])

4.2 查看代码

命令含义
llist,【优先展现main函数】
l 10当前文件第10行
l fun.cc:10fun.cc 的第10行
l fun.cc:My_funfun.cc 的 My_fun 函数

4.3 设置断点

命令含义
b 10break 10第十行打断点
b mainbreak mainmain 函数处打断点
b fun.cc:My_funfun.cc 的 My_fun 函数处打断点
b 10 if i==10第10行,i 等于 10 的时候才停
del 「断点的编号」d「断点的编号」删除断电
info bi b显示所有断点编号

4.4 查看变量

命令含义
p iprint i查看变量 i 的值
ptype iptype i查看变量 i 的类型

4.5 设置变量

set var 变量名 = 值,直接跳到该状态。

例子:在for循环中,想直接看到当 i=10 的时候的结果。

1
set var i = 10

4.6 设置追踪变量

跟踪变量,就是在每一步执行的时候,会显示 被跟踪变量 的值。

命令含义
display 编号跟踪变量
undisplay 编号取消跟踪变量
info display查看追踪变量的编号

4.7 退出 gdb 调试

quit

5.makefile

5.1 命名

makefile 或者 Makefile

5.2 规则简单格式

(1)三要素:目标、依赖、命令

(2)基本格式:

1
2
目标:依赖
编译命令
1
2
app:main.cc sub.cc mul.cc
g++ main.cc sub.cc mul.cc -o app -I include -L lib -l Mylib

5.3 多条规则

问题:如果有很多的文件需要编译,当修改其中一个文件时,为了避免重新编译所有文件,将文件分开编译。

解决:默认第一条的目标为终极目标,第一条规则的依赖如果不存在,就向下面的其他规则中查找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app:main.o sub.o add.o mul.o div.o
g++ main.o sub.o add.o mul.o div.o -o app

main.o:main.cc
g++ main.cc -c

sub.o:sub.cc
g++ sub.cc -c

add.o:add.cc
g++ add.cc -c

mul.o:mul.cc
g++ mul.cc -c

div.o:div.cc
g++ div.cc -c
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
2
3
4
5
6
7
obj=main.o add.o sub.o mul.o
target=app
$(target):$(obj)
g++ $(obj) -o app

%.o:%.cc
g++ -c $< -o $@

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
    4
    CC=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
2
3
4
5
6
7
8
src=$(wildcard ./*.cc)                  # 查找所有 .cc 文件名,储存在 src 变量中。
obj=$(patsubst ./%.cc, ./%.o, $(src)) # 将 $(src) 中的文件名,全部替换成 .o 文件名,并储存在 obj 变量中。
target=app
$(target):$(obj)
g++ $(obj) -o app

%.o:%.cc
g++ -c $< -o $@

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
2
3
4
5
6
7
8
9
10
11
12
src=$(wildcard ./*.cc)
obj=$(patsubst ./%.cc, ./%.o, $(src))
target=app
$(target):$(obj)
g++ $(obj) -o app

%.o:%.cc
g++ -c $< -o $@

# 以下代码是重点,只写目标、命令,没有依赖。
clean:
rm -rf app
  • 直接 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
2
3
4
./PHONY:clean
clean:
4rm app # 命令1 报错
4echo “Finish clean” # 命令2 继续执行

只需要在命令1前加入 - 即可。

1
2
3
4
./PHONY:clean
clean:
- rm app # 命令1 报错
echo “Finish clean” # 命令2 继续执行

结果如下,会忽略报错:

1
2
3
4
5
6
➜~ make clean  
rm app
rm: 无法删除"app": 没有那个文件或目录
make: [clean] 错误 1 (忽略)
echo "Finish clean"
Finish clean

5.8 其他目标

如同 clean 目标一样,我们还可以加入一些其他的自定义目标。

比如 hello 目标:

1
2
3
4
./PHONY:hello
hello:
-echo "welcome"
mkdir ./welcome

只要 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
2
3
file a.out
# 显示信息
a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=654398ee82c59c781dead9831ea14d7944bd453e, not stripped

(3)受保护的地址

这个区域很小,用户不可操作,在 C 语言中,NULL 其实是宏定义,#define NULL (void*)0 ,也就是说,空指针其实指的就是这个地址。

PS:在 C++ 中,由于不允许 void* 自动转换别的类型指针,所以 NULL 的宏定义是 #define NULL 0,为了消除二义性(比如函数重载),推荐使用nullptr。

(4)虚拟地址空间作用

  1. 方便编译器和操作系统安排程序的地址分布。

    程序可以使用 连续的虚拟地址空间 来访问物理内存中 不连续的内存缓冲区。

  2. 方便进程之间的隔离

    不同进程使用的虚拟地址彼此隔离,一个进程中的代码无法修改正在由另外一个进程使用的物理内存。

  3. 方便操作系统使用稀缺的内存

    程序可以是连续的虚拟地址空间,来访问比 可用物理内存 大的内存缓冲区。

    当物理内存可用量变小时,内存管理会将物理内存页(通常大小为 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
yum install manpages-zh

切换英文。

1
man -L en man

6.6 open

(1)介绍

man 2 open

2 表示帮助文档第二章节(系统调用,)

1
2
3
4
5
6
7
8
#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); // 打开不存在的文件,即创建文件

int creat(const char *pathname, mode_t mode);
  • 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
2
3
4
5
6
7
8
9
10
#define EPERM            1      /* Operation not permitted */
#define ENOENT 2 /* No such file or directory */
#define ESRCH 3 /* No such process */
#define EINTR 4 /* Interrupted system call */
#define EIO 5 /* I/O error */
#define ENXIO 6 /* No such device or address */
#define E2BIG 7 /* Argument list too long */
#define ENOEXEC 8 /* Exec format error */
#define EBADF 9 /* Bad file number */
...
  • 当调用系统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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h> // close 的头文件
#include <stdio.h> // perror 的头文件
#include <stdlib.h> // exit 的头文件
#include <iostream>
using namespace std;

int main() {
int fd;

// 打开已存在的文件
fd = open("hello.txt", O_RDWR);
if (fd == -1) {
perror("open file");
exit(1);
}

// 关闭文件
int ret = close(fd);
cout << "ret = " << ret << endl;
if (ret == -1) {
perror("close file");
exit(1);
}
return 0;
}

(5)open 函数使用-O_CREAT

需求:创建新文件

  • 创建文件需三个参数,O_RDWR(可读写)、O_CREAT(创建)、0777(权限)
  • 关于权限:虽然给定权限是 0777,但是文件实际权限是 给定权限 - 本地掩码
  • 关于掩码: 命令 umask 可以查看本地掩码,umask 002 可以更改本地掩码为 002。
  • 举个例子:给定权限是 0777,本地掩码是 022,实际权限就是:0777-022=0755
  • 实际过程是 本地掩码取反 后,和 给定文件权限 按位与。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h> // close 的头文件
#include <stdio.h> // perror 的头文件
#include <stdlib.h> // exit 的头文件
#include <iostream>
using namespace std;

int main() {
int fd;

// 创建不存在的新文件
fd = open("new_hello.txt", O_RDWR | O_CREAT, 0777);
if (fd == -1) {
perror("open file");
exit(1);
}

// 关闭文件
int ret = close(fd);
cout << "ret = " << ret << endl;
if (ret == -1) {
perror("close file");
exit(1);
}
return 0;
}

(6)open 函数使用-O_EXCL

需求:判断为文件是否存在

  • O_CREAT 和 O_EXCL 同时使用。
  • 在 创建 之前,如果文件存在,会更改 errno,可以用 perror 打印出来。
  • 如果没有 O_EXCL,却有 O_CREAT,打开已存在的文件无提醒,并不更改已存在的文件内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h> // close 的头文件
#include <stdio.h> // perror 的头文件
#include <stdlib.h> // exit 的头文件
#include <iostream>
using namespace std;

int main() {
int fd;

// 判断文件是否存在
fd = open("new_hello.txt", O_RDWR | O_CREAT | O_EXCL, 0777);
if (fd == -1) {
perror("open file");
exit(1);
}

// 关闭文件
int ret = close(fd);
cout << "ret = " << ret << endl;
if (ret == -1) {
perror("close file");
exit(1);
}
return 0;
}

(7)open 函数使用-O_TRUNC

需求:将文件截断为0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h> // close 的头文件
#include <stdio.h> // perror 的头文件
#include <stdlib.h> // exit 的头文件
#include <iostream>
using namespace std;

int main() {
int fd;

// 将文件截断为0
fd = open("new_hello.txt", O_RDWR | O_TRUNC);
if (fd == -1) {
perror("open file");
exit(1);
}

// 关闭文件
int ret = close(fd);
cout << "ret = " << ret << endl;
if (ret == -1) {
perror("close file");
exit(1);
}
return 0;
}

6.7 read

(1)介绍

读取的内容,写入 buf,count 表示 buf 的大小。

ssize_t:是有符号的 int。

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

(2)返回值

  • -1:读文件失败,并设置了 errno 的值,可以用 perror 打印。
  • 0:文件读完(读到文件末尾、管道写端关闭、socket 对端关闭)
  • >0:实际读取的字节数量

6.8 write

(1)介绍

将 buf 的内容写入 fd 所指的文件。count 表示 buf 的大小。

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

(2)注意

会覆盖原文件,但是不会清空。

6.9 lseek

(1)用处:

  • 获取文件大小(同 fseek)

    1
    2
    int size = lseek(fd, 0, SEEK_END);
    // 返回值 = 当前指针的位置
  • 移动文件指针(同 fseek)

  • 拓展文件(fseek 没有的功能):用无意义字符填充,扩展文件大小。

    比如:拓展成为空洞文件。多线程下载一个 1G 大小的文件时,在开始下载时,便会生成一个 1G 大小的空洞文件,这样每个线程就知道写入的分别是哪个位置。

(3)注意:实现拓展文件,需要使用 lseek 函数之后,进行一次写操作 write。

(4)介绍

1
2
3
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
  • off_t 是 int。返回值为当前指针的位置。
  • offset 是文件指针的偏移量。
  • whence 可以有三个值:
    • SEEK_SET:开始位置
    • SEEK_CUR:当前位置
    • SEEK_END:结尾位置

(5)拓展文件实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <sys/types.h>  // open 头文件
#include <sys/stat.h> // open 头文件
#include <fcntl.h> // open 头文件
#include <unistd.h> // close 头文件
#include <stdio.h> // perror 头文件
#include <stdlib.h> // exit 头文件
#include <iostream>
using namespace std;
int main() {
// 1.打开文件
int fd = open("aa", O_RDWR);
if (fd == -1) {
perror("open file");
exit(1);
}
// 2.指定 偏移量 = 10,开始偏移位置 = SEEK_END
int ret = lseek(fd, 10, SEEK_END);
cout << "cur_file_length = " << ret << endl;

// 3.做一次写操作
write(fd, "i", 1);

close(fd);
return 0;
}

(6)实例:复制文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h> // close 的头文件
#include <stdio.h> // perror 的头文件
#include <stdlib.h> // exit 的头文件
#include <iostream>
using namespace std;

int main() {
// 打开被读文件
int fd = open("a.out", O_RDONLY);
if (fd == -1) {
perror("error:");
exit(1);
}

// 打开拷贝后文件
int fd1 = open("new", O_CREAT | O_WRONLY, 777);
if (fd1 == -1) {
perror("creat_error:");
exit(1);
}

// 循环拷贝
char buf[2048] = {0};
int res = read(fd, buf, sizeof(buf));
if (res == -1) {
perror("read");
exit(1);
}
while(res) {
write(fd1, buf, res);
cout << res << "writed" << endl;
res = read(fd, buf, sizeof(buf));
if (res == -1) {
perror("read");
exit(1);
}
}

// 关闭文件
close(fd);
close(fd1);
return 0;
}

7.Linux 文件操作函数

7.1 stat 命令

打印信息节点(inode)内容

1
2
3
4
5
6
7
8
9
10
stat lseek.cc
# 文件:"lseek.cc"
# 大小:473 块:8 IO 块:4096 普通文件
# 设备:fd00h/64768d Inode:50638303 硬链接:1
# 权限:(0644/-rw-r--r--) Uid:( 0/ root) Gid:( 0/ root)
# 环境:unconfined_u:object_r:admin_home_t:s0
# 最近访问:2021-01-26 21:16:08.927377109 +0800
# 最近更改:2021-01-26 21:16:06.094350595 +0800
# 最近改动:2021-01-26 21:16:06.098684097 +0800
# 创建时间:-

PS:索引节点 inode:其本质为结构体 struct stat,存储文件的属性信息。这些信息被称为元数据。

元数据:文件大小,设备标识符,用户组标识符,文件模式,扩展属性,文件读取或修改的时间戳,链接数量,指向储存该数据的磁盘区块的指针,文件分类。

注意:数据分为 元数据 和 数据本身。

7.2 stat 函数

(1)介绍

1
2
3
4
5
6
7
8
9
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

// 传入路径
int stat(const char *path, struct stat *buf); // buf 为传出参数
// 传入文件描述符
int fstat(int fd, struct stat *buf);
int lstat(const char *path, struct stat *buf);

(2)权限属性

在 struct stat 结构体中,有一个变量为:mode_t st_mode; 表示文件的类型和存取的权限。

一共 2 Byte,16位。

1-4bit5-7bit8-10bit11-13bit14-16bit
文件类型特殊权限位UserGroupOthers

举个例子,如果想拿到 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <iostream>
using namespace std;

int main(int argc, char** argv) {
// 判断参数是否正确
if (argc < 2) {
cout << "./a.out filename" << endl;
exit(1);
}

// 建立结构体空间(传出参数)
struct stat ans;

// 获取节点信息
int ret = lstat(argv[1], &ans);
if (ret == -1) {
perror("stat");
exit(1);
}

// 打印传出参数的变量
int size = (int)ans.st_size; // 返回值是 off_t,需要强制转换为 int
cout << "size = " << size << endl;
return 0;
}

7.3 lstat 函数

stat 和 lstat 区别:

  • stat:追踪函数,st_size 是软连接所指向的文件的大小。
  • lstat:不追踪,st_size 是软连接文件的大小。

举个例子:

  • ls -l、rm:不追踪命令
  • vi:追踪命令

7.4 access 函数

(1)介绍

测试指针文件是否拥有某种权限。

1
2
#include <unistd.h>
int access(const char *pathname, int mode);
  • pathname:文件名

  • mode:权限类别

    • R_OK:是否拥有读权限
    • W_OK:是否有些权限
    • X_OK:是否有执行权限
    • F_OK:测试一个文件是否存在
  • 返回值:

    • 0:所有检查的权限都通过了测试
    • -1:有权限被禁止

(2)实例:测试是否拥有执行权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
using namespace std;

int main(int argc, char **argv) {
if (argc < 2) {
cout << "./a.out filename" << endl;
exit(1);
}
int ret = access(argv[1], X_OK);
if (ret == -1) {
perror("access");
exit(1);
}
cout << "You can write this flie" << endl;
return 0;
}

7.5 chmod 函数

(1)介绍

1
2
3
4
#include <sys/stat.h>

int chmod(const char *path, mode_t mode); // 传文件路径
int fchmod(int fd, mode_t mode); // 传文件描述符
  • mode_t mode:同int open(const char *pathname, int flags, mode_t mode);

    如果指定权限为 0777, 则不需要权限与取反后掩码进行按位与操作。

(2)实例:更改文件权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
using namespace std;
int main(int argc, char** argv) {
if (argc < 2) {
cout << "./a.out filename" << endl;
exit(1);
}

// 将文件权限修改成 0777
int ret = chmod(argv[1], 0777);
if (ret == -1) {
perror("chmod");
exit(1);
}
return 0;
}

(3)问题

在上面实例中,chmod(argv[1], 0777) ,0777 是写进程序的,如果想要改变,需要重新编译,可不可以通过参数传入?

可以,但要注意 参数是字符串chmod 形参是八进制数。需要使用 strtol 函数进行转换,用法见 strtol 函数

(4)实例:利用 strtol 函数为 chmod 函数传入权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
using namespace std;
int main(int argc, char** argv) {
// 对参数个数进行判断,防止越界
if (argc < 3) {
cout << "./a.out file_name limit_num" << endl;
exit(1);
}
// 获取权限,将字符串转化为八进制数字
int limit_num = strtol(argv[2], NULL, 8);
// 更改权限
int ret = chmod(argv[1], limit_num);
if (ret == -1) {
perror("chmod");
exit(1);
}
return 0;
}

7.6 strtol 函数

(1)介绍

字符串转化为整型。

1
2
3
#include <stdlib.h>

long int strtol(const char *nptr, char **endptr, int base);
  • const char *nptr:0777,待转换的字符串
  • char **endptr:NULL,忽略
  • int base:8,表示八进制

(2)实例:将 0777 转换为 int 数值

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <stdlib.h>
using namespace std;

int main(int argc, char** argv) {

cout << strtol("755", NULL, 10) << endl; // 493,直接打印,会按照十进制打印出来。
cout << strtol("argv[1]", NULL, 10) << endl;
return 0;
}

注意: cout << strtol("755", NULL, 10) << endl; 流程如下:

  • 将字符串 “755” 转化为整型 755(八进制 OCT)。
  • 将八进制 755 转换为十进制 493 打印。

7.7 chown 函数

(1)介绍

更改所有者。

1
2
3
4
5
#include <unistd.h>

int chown(const char *path, uid_t owner, gid_t group);
int fchown(int fd, uid_t owner, gid_t group); // 跟踪软连接
int lchown(const char *path, uid_t owner, gid_t group); // 不跟踪软链接
  • uid_t owner:所有者的 uid。
  • gid_t group:所属组的 gid。

(2)查看 uid 和 gid

  • 以第 4 行为例,3 代表 uid, 4代表 gid。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vim /etc/passwd
# 登录名:加密后的密码:uid:gid:用户名:用户主目录
# 1 root:x:0:0:root:/root:/bin/zsh
# 2 bin:x:1:1:bin:/bin:/sbin/nologin
# 3 daemon:x:2:2:daemon:/sbin:/sbin/nologin
# 4 adm:x:3:4:adm:/var/adm:/sbin/nologin
# 5 lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
# 6 sync:x:5:0:sync:/sbin:/bin/sync
# 7 shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
# 8 halt:x:7:0:halt:/sbin:/sbin/halt
# 9 mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
#10 operator:x:11:0:operator:/root:/sbin/nologin
#11 games:x:12:100:games:/usr/games:/sbin/nologin
#12 ftp:x:14:50:FTP User:/var/ftp:/sbin/nologin
#13 nobody:x:99:99:Nobody:/:/sbin/nologin
# ...

(3)实例:更改所属信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <iostream>
#include <unistd.h>
using namespace std;

int main(int argc, char** argv) {
if (argc < 2) {
cout << "./a.out filename" << endl;
exit(1);
}
// 更改所有者和所属组
int ret = chown(argv[1], 1001, 1001);
if (ret == -1) {
perror("chown");
exit(1);
}
return 0;
}

7.8 truncate 函数

专门做函数扩展的函数。

1
2
3
4
5
#incldue <unistd.h>
#include <sys/types.h>

int truncate(const char *path, off_t length);
int ftruncate(int fd, off_t length);
  • off_t length :指定文件大小。
    • length > 源文件size,扩展成空洞文件。
    • length < 源文件size,截断文件,后面的字符全部截断。
  • 返回值:0 –> 成功,-1 –> 失败。

创建一个硬连接。

1
2
3
#include <unistd.h>

int link(const char* oldpath, const char* newpath);

创建一个软连接。

1
2
3
#include <unistd.h>

int symlink(const char* oldpath, const char* newpath);

读出软连接,读到的内容为:软连接所指文件的路径。

1
2
3
#include <unistd.h>

ssize_t readlink(const char *path, char *buf, size_t bufsize);
  • char *buf:传出参数,内容为软连接所指文件的路径。
  • const char *path:软连接的路径。

(1)作用:

  • unlink 软连接:删除一个软链接。

  • unlink 普通文件:也就是 unlink 硬链接。硬链接计数减 1,当减为0,释放数据块和 inode。

  • 制作临时文件

    创建一个新的文件,并使用 unlink 释放该文件,文件就会被自动释放。(用于缓存)

(2)介绍:

1
2
3
#include <unistd.h>

int unlink(const char *pathname);

(3)实例:创建缓存文件,并自动清理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
int main(int argc, char** argv) {
//创建新文件
int fd = open("tempfile", O_CREAT | O_RDWR, 777);
if (fd == -1) {
perror("open file");
exit(1);
}
// 释放硬链接(普通文件),但是此时该文件被进程使用,所以不会立刻释放空间。
int ret = unlink("tempfile");
// 写入内容
write(fd, "hello", 5);
// 重置指针
lseek(fd, 0, SEEK_SET);
// 将临时文件中的内容读入到 buf。
char buf[1024] = {0};
int len = read(fd, buf, sizeof(buf));
// 将 buf 的内容写入 标准输出(文件描述符 1)
write(1, buf, len);
return 0;
}

7.13 rename 函数

修改文件命名。

1
2
3
#include <stdio.h>

int rename(const char* oldpath, const char* newpath);

8.目录操作函数

8.1 chdir 函数

将当前进程的路径改为 path。

1
2
3
4
#include <unistd.h>

int chdir(const char* path);
int fchdir(int fd);
  • 返回值:成功 –> 0,失败 –> -1。

8.2 getcwd 函数

获取当前进程工作目录。类似于 pwd。

1
char *getcwd(char* buf, size_t size);

将当前进程所在路径写入 buf 当中。

8.3 mkdir 函数

创建目录。注意:目录需要有执行权限才能打开。

1
int mkdir(const char* pathname, mode_t mode);

8.4 opendir 函数

打开目录。

1
2
3
4
#include <sys/types.h>
#include <dirent.h>

DIR *opendir(const char* name);
  • 返回值:
    • DIR 结构指针,该结构是一个内部结构,保存所打开的目录信息,作用类似于 FILE 结构。
    • 函数出错返回 NULL。

8.5 readdir 函数

读目录。

1
struct dirent *readdir(DIR *dirp);
  • 返回值:返回一条记录项 struct dirent,含有变量如下。
1
2
3
4
5
6
7
struct dirent {
ino_t d_ino; // 此目录进入点的 inode
ff_t d_off; // 目录文件开头至此目录进入点的位移,就是 off_t 类型,表示偏移量,进入到第几层了
signed short int d_reclen; // d_name 的长度,不包括 NULL 字符
unsigned char d_type; // d_name 所指的文件类型
har d_name[256]; // 文件名
}
  • 其中,d_type 的类型。
文件类型
DT_BLK块设备
DT_CHR字符设备
DT_DIR目录
DT_LNK软连接
DT_FIFO管道
DT_REG普通文件
DT_SOCK套接字
DT_UNKNOWN未知

8.6 closedir 函数

关闭目录。

8.7 递归读目录获取文件个数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <sys/types.h>
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int getFileNum(char *root) {
// 打开文件
DIR* dir = NULL;
dir = opendir(root);
if (!dir) {
perror("opendir");
exit(1);
}


int total = 0;
struct dirent* ptr = NULL;
char buf[1024] = {0};

// 循环遍历当前目录
while ( (ptr = readdir(dir)) != NULL ) {

// 过滤 . 和 .. 目录
if (strcmp(ptr->d_name, ".") == 0 || strcmp(ptr->d_name, "..") == 0) {
continue;
}

// 遍历到目录,递归进入
else if (ptr->d_type == DT_DIR) {
sprintf(buf, "%s/%s", root, ptr->d_name);
total += getFileNum(buf);
}

// 遍历到普通文件或连接文件,计数
else if (ptr->d_type == DT_REG || ptr->d_type == DT_LNK) {
total++;
}
}

// 关闭文件
closedir(dir);
return total;
}

int main(int argc, char** argv) {
// 防止越界
if (argc < 2) {
printf("./a.out Filename");
exit(1);
}
// 获取文件总数
int total = getFileNum(argv[1]);
printf("%d\n", total);
return 0;
}

8.8 dup 和 dup2

复制文件描述符。

1
2
3
4
#include <unistd.h>

int dup(int oldfd);
int dup2(int oldfd, int newfd);
  • dup 返回值:返回文件描述符中没有被占用的最小的文件描述符。

  • dup2 返回值:返回 newfd 的值。

  • 情况分析:

    • newfd 是一个被打开的文件描述符,在拷贝前先关掉 new。
    • oldfd 和 newfd 是一个文件描述符。不会关闭 new,直接返回 old。
  • 复制之后,两个文件描述符会指向同一个文件:

    • 两个文件描述符指向一个文件,但是文件指针只有一个。举个例子:如果通过一个文件描述符写入内容,不人工移动文件指针;另一个文件描述符再写入的时候,因为文件指针在末尾,所以会追加写入。

8.9 fcntl 函数

(1)作用

  • 复制现有的文件描述符

  • 获取/设置文件描述符的标记

  • 获取/设置异步IO所有权

  • 获取/设置记录锁

  • 【重要】获取/设置文件状态标记(可修改已经打开的文件权限)

    比如:打开文件的时候:只读;修改文件的权限:追加 O_APPEND

(2)介绍

1
2
3
#include <fcntl.h>

int fcntl(int fd, int cmd, long arg);
  • 返回值:
    • 获取状态失败 –> -1;
    • 获取状态成功 –> 0;

(3)F_GETFL:获取文件状态

1
int flag = fcntl(my_fd, F_GETFL, 0);   // 获取状态时,arg 参数置 0 即可。

cmd 参数:

含义
O_RDONLY只读打开
O_WRONLY只写打开
O_RDWR读写打开
O_EXEC执行打开
O_SEARCH搜索打开目录
O_APPEND追加写(不会覆盖)
O_NONBLOCK非阻塞模式

(4)F_SRTFL:更改文件状态

1
2
3
4
5
6
7
8
// 获取文件状态
int flag = fcntl(my_fd, F_GETFL, 0);

// 修改状态
flag |= O_APPEND;

// 修改文件状态
fcntl(my_fd, F_SRTFL, flag); // 设置状态时,arg 参数 传入 flag(被更改的状态)。
可更改的宏含义
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ulimit -a  
# -t: cpu time (seconds) unlimited
# -f: file size (blocks) unlimited
# -d: data seg size (kbytes) unlimited
# -s: stack size (kbytes) 8192 【栈空间】
# -c: core file size (blocks) 0
# -m: resident set size (kbytes) unlimited
# -u: processes 3796
# -n: file descriptors 1024 【文件描述符表的上限】
# -l: locked-in-memory size (kbytes) 64
# -v: address space (kbytes) unlimited
# -x: file locks unlimited
# -i: pending signals 3796
# -q: bytes in POSIX msg queues 819200
# -e: max nice 0
# -r: max rt priority 0
# -N 15: unlimited

9.4 进程状态

进程的状态:就绪、运行(执行)、阻塞(挂起)、终止、初始(创建)。

9.5 fork 函数

(1)作用

创建子进程。

(2)介绍

1
2
3
#include <unistd.h>

int fork(void)
  • 返回值:有两个返回值。当 fork 成功时,就会有两个相同的进程,也就意味着有两个 fork 函数,父子进程的 fork 函数返回值不一样。
    • 父进程:成功返回子进程的 PID。
    • 子进程:成功返回 0。
    • 失败:父进程返回一个负值。

(3)关系

  • 子进程是父进程的拷贝。
  • 子进程和父进程的 用户区 数据相同,内核区 不同。(唯一的不同是:内核区中的PCB中的PID不一样)

(4)子进程执行的位置

从 fork 函数执行后的位置开始执行。以下列代码为例,子进程从第4行开始执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char **argv) {
cout << "begin fork" << endl;
pid_t pid = fork();

// 父进程
if (pid > 0) {
std::cout << "parent process, pid = " << getpid() << std::endl;
}
// 子进程
if (pid == 0) {
std::cout << "child process, pid = " << getpid() << std::endl;
}
std::cout << pid << std::endl;
return 0;
}

(5)父子进程的执行顺序

不一定,谁抢到 CPU,谁先执行。

可以在代码中添加 sleep(1) 来测试。(sleep 会使进程让出 CPU)

(6)shell 切换时刻提前

当父进程执行完成,shell 会切换到前台,此时子进程还没执行完,依旧会在打印内容。会出现以下情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
➜  fork ./a.out
parent process, pid = 39744
39745
i = 0
i = 1
i = 2
i = 3
child process, pid = 1
➜ fork 0 # 父进程结束,shell 切换到前台,打印出终端提示符,此时子进程还在输出内容。
i = 0
i = 1
i = 2
i = 3 # 子进程结束完毕,不会有终端提示符(已打印过)

(7)log 输出顺序不能代表执行顺序

可能一个进程先执行,并且先执行一大批代码后,sleep了,只是没有打印 log。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(int argc, char **argv) {
pid_t pid = fork();

if (pid > 0) {
std::cout << "这里代表一大堆业务代码" << std::endl;
sleep(1); // 执行完一大批代码后 sleep 了。
std::cout << "parent process, pid = " << getpid() << std::endl;
}
if (pid == 0) {
std::cout << "child process, pid = " << getpid() << std::endl;
}
return 0;
}

# 结果:
# 这里代表一大堆业务代码
# child process, pid = 21642
# parent process, pid = 21641

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
3
4
5
6
7
8
9
int main(int argc, char **argv) {
pid_t pid;

for (int i = 0; i < 3; i++) {
pid = fork();
}
std::cout << getpid() << std::endl;
return 0;
}

(2)阻止子进程 fork

父进程只会创建三个子进程。

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, char **argv) {
pid_t pid;

for (int i = 0; i < 3; i++) {
pid = fork();
if (!pid) { // 阻止子进程 fork
break;
}
}
std::cout << getpid() << std::endl;
return 0;
}

(3)判断是第几个子进程

一定要 sleep 父进程,否则子进程就可能为僵尸进程,ppid = 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <unistd.h>
#include <iostream>
using namespace std;

int main(int argc, char **argv) {
pid_t pid;
int i = 0;

// 循环创建进程
for ( i = 0; i < 3; i++) {
pid = fork();
if (!pid) {
break;
}
}

// 判断是第几个进程
if (i == 0) {
cout << "First process, pid = " << getppid() << endl;
} else if (i == 1) {
cout << "Second process, pid = " << getppid() << endl;
} else if (i == 2) {
cout << "Three process, pid = " << getppid() << endl;
} else if (i == 3) {
sleep(1); // 【一定要 sleep 父进程,否则子进程的 ppid 就可能为 1】
cout << "Father process, pid = " << getpid() << endl;
}
return 0;
}

9.9 ps 和 kill

学习笔记|Linux|十五、进程管理

1
ps ajx

可以查看父进程PPID、进程组PGID、会话SID

注意:能查出结果,不一定存在进程。(可能是 grep 进程)

1
2
ps aux | grep process_name
# root 24013 0.0 0.0 4268408 576 s002 R+ 1:13 0:00.00 grep --color=auto process_name

9.10 进程组和会话

进程组:多个进程

会话:多个进程组

1
2
3
4
#include <unistd.h>

pid_t getpgid(pid_t pid); // 获取进程组id
pid_t getsid(pid_t pid); // 获取会话id

9.11 进程间的数据共享

先讲结论:各进程的数据完全独立,读时共享,写时复制

(1)问题:变量独立,地址为什么相同

如下代码,通过修改 num 变量,发现父子进程的 num 变量是相互独立的,为什么 num 的地址值却相等?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main() {
int num = 10;
pid_t pid = fork();
if (pid > 0) {
num = 5;
cout << "father_process: num = " << num << " &num = " << &num << endl;
} else {
num = 6;
cout << "sun_process: num = " << num << " &num = " << &num << endl;
}
return 0;
}

// father_process: num = 5 &num = 0x7ffee19317b8
// sun_process: num = 6 &num = 0x7ffee19317b8

(2)解决:因为虚拟内存空间

  • 父子进程都有自己的一份虚拟地址空间,相互独立。
  • &num 打印的是虚拟地址空间,而不是真正的物理内存。只代表在当前 虚拟地址空间 中的位置,因为 num 是在 fork 之前就初始化的,所以父子进程的 num 在 虚拟地址空间 中的位置相同。
  • 虚拟内存空间,实际映射到一个物理内存上。

重点:读时共享、写时复制

  • 对于 读操作,父子进程中的 num 会映射到物理内存中的同一个地方【好处:节省空间,提高效率】。
  • 父进程发生 写操作,物理内存会拷贝一份 num 变量,供父进程写入。
  • 子进程发生 写操作,物理内存会拷贝一份 num 变量,供子进程写入。
  • PS:复制时,不会调用拷贝构造。猜测是物理层面上的拷贝。

9.12 execl 函数族

在进程中调用其他程序。

(1)问题

父子进程分别执行不同的操作,需要判断。如果把多个父子进程的业务逻辑全部写在一个文件中,不好控制。

(2)解决

execl 函数族:替换进程地址空间中的源代码(.txt段)。

(3)介绍

1
2
3
#include <unistd.h>

int execl(const char* path, const char* arg, ..., NULL);
  • path:需要执行的程序的绝对路径(建议)。
  • 变参 arg:占位符 + 需要执行的程序所需的参数。
    • 第一个 arg:占位符,随便写什么,一般写成执行程序的名字。
    • 后面的 arg:需要执行的程序所需的参数。
  • 参数列表最后加上 NULL。
  • 返回值:失败返回 -1,但是使用不到。
    • 成功:text 段被替换,没办法执行对返回值的判断。
    • 失败:直接用 perror 打印保存信息即可。

PS:一般执行自己写的程序。

(4)实例

子进程调用 ls 命令。

注意: 子进程 execl 替换程序后,子进程不会执行之后的代码。

注意: 新程序不会开辟新的地址空间,直接使用子进程的地址空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <unistd.h>
#include <iostream>
int main() {
std::cout << "Test of exec..." << std::endl;
pid_t pid = fork();
if (pid > 0) {
sleep(2);
} else if (pid == 0) {
execl("/bin/ls", "ls", "-a", NULL); // 绝对路径。
}

if (pid > 0) {
std::cout << "Father process end." << std::endl; // 会执行
} else if (pid == 0) {
std::cout << "Sun process end." << std::endl; // 不会执行,因为代码已被替换
}
return 0;
}

(5)调用其他程序失败

如果执行成功不会 text 代码被替换,第 2 行代码不会执行。如果执行了判断,也意味着调用其他程序失败。

1
2
3
4
5
6
7
8
9
// 多此一举的判断
int ret = execl("/bin/ls", "ls", "-a", NULL);
if (ret == -1) {
perror("execl");
}

// 争取的写法
execl("/bin/ls", "ls", "-a", NULL);
perror("execl");

9.13 execlp 函数

和 execl 的区别在于,execlp 执行的是 PATH 环境变量中能够搜索到的程序。

(1)介绍

1
2
3
#include <unistd.h>

int execlp(const char* file, const char* arg, ..., NULL);
  • file: PATH 环境变量中能够搜索到的程序名称,不需要指定路径。

  • 其他参数:同 execl。

  • 返回值:同 execl。

  • 注意:执行系统自带的程序(通过PATH变量查找)。如果一定执行自定义程序,必须绝对路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <unistd.h>
#include <iostream>
int main() {
std::cout << "Test of exec..." << std::endl;
pid_t pid = fork();
if (pid > 0) {
sleep(2);
} else if (pid == 0) {
execl("ls", "ls", "-a", NULL); // 直接写程序名称。
}

if (pid > 0) {
std::cout << "Father process end." << std::endl; // 会执行
} else if (pid == 0) {
std::cout << "Sun process end." << std::endl; // 不会执行,因为代码已被替换
}
return 0;
}

9.14 孤儿进程

(1)孤儿进程的始终

  • 父进程 fork 子进程。

  • 父进程结束,子进程还在。子进程就叫做孤儿进程。

  • 孤儿进程被 init 进程管理,init 进程成为了孤儿进程的父进程。

(2)问题:为什么 init 进程要领养孤儿

  • 进程结束后,才能释放用户空间。
  • 但是无法释放内核区的 PCB。
  • PCB 必须由父进程来释放。

(3)实例

  • 让子进程 sleep,父进程执行结束后,子进程就变成了孤儿进程。
  • 在父进程执行前后,打印子进程的 ppid,查看 ppid 的变化。
    • 父进程结束前,子进程的 ppid 为父进程的 pid
    • 父进程结束后,子进程的 ppid 为 1,表示 init 进程领养了孤儿进程,init 进程成为了 子进程的父进程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <unistd.h>
#include <iostream>
using namespace std;

int main() {
pid_t pid = fork();
if (pid > 0) {
sleep(1);
cout << "Parent process is end. " << "pid = " << getpid() << endl;
} else {
cout << "Sun process is end. " << "ppid = " << getppid() << endl;
sleep(2);
cout << "Sun process is end. " << "ppid = " << getppid() << endl;
}
return 0;
}

// Sun process is end. ppid = 50902 // 父进程结束前,子进程的 ppid 为父进程的 pid
// Parent process is end. pid = 50902
// Sun process is end. ppid = 1 // 父进程结束后,子进程的 ppid 为 1,表示 init 进程领养了孤儿进程,init 进程成为了 子进程的父进程。

9.15 僵尸进程

(1)僵尸进程的始终

  • 父进程 fork 子进程。
  • 子进程结束,父进程还在。
  • 父进程不释放子进程的 PCB。子进程就叫做僵尸进程。

PS:僵尸进程是死掉的进程。kill 无法杀死僵尸进程。

(2)实例

  • 让父进程一直在忙,无限循环打印 1。父进程没有机会释放子进程的PCB控制块。
  • 子进程结束后,成为僵尸进程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <unistd.h>
#include <stdio.h>
#include <iostream>
using namespace std;

int main() {
pid_t pid = fork();
if (pid > 0) {
while (1) {
cout << "1 ";
if (i % 7 == 0) // 为避免一直打印,每七睡眠和刷新缓冲区。
fflush(stdout);
sleep(2);
}
}
} else {
cout << endl << "Sun process end." << endl; // 子进程结束。
}
return 0;
}

进程后面显示 <defunct>,并显示 STAT = Z+,即代表僵尸进程。

PS:zombie 表示僵尸,Z+ 中的 Z 就是 zombie 的缩写。

1
2
3
ps ajx | grep a.out
# 58824 61407 61407 58824 pts/0 61407 S+ 0 0:00 ./a.out
# 61407 61408 61407 58824 pts/0 61407 Z+ 0 0:00 [a.out] <defunct>

9.16 进程回收

(1)wait 阻塞函数

阻塞条件:子进程死亡。

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int* status);
  • 返回值:

    • -1:回收失败,代表没有子进程了。

      1
      2
      3
      4
      // 可以利用 返回值 做循环条件,循环回收多个子进程。
      while (wait(&status) != -1) {
      // ...
      }
    • > 0:回收成功,返回子进程对应的 pid。

  • status:判断子进程是如何死亡的。

    • 正常退出。
    • 被某个信号杀死。
  • 调用一次只能回收一个子进程。

(2)实例

在父进程中调用 wait 函数,wait 会阻塞,直到子进程结束,wait 函数才会执行结束,父进程才能继续执行下面的代码。

wait 回收成功,会返回子进程的 pid,我们打印 wait 返回值,来判断是否回收成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main() {
pid_t pid = fork();
if (pid > 0) {
cout << "pid = " << pid << endl;
int w_pid = wait(NULL);
cout << "sun process is end, w_pid = " << w_pid << endl;
} else if (pid == 0) {
sleep(2);
cout << "(sun)pid = " << getpid() << endl;
}
for (int i = 0; i < 3; i++) {
cout << i << endl;
}
return 0;
}

(3)status 的使用

  • 如果不关注子进程是如何死亡的,status 参数写 NULL。

status:判断子进程是如何退出的?步骤如下:

  1. 定义 status 变量。

  2. wail 函数传入 &status。(status 是 wail 的传出参数)

  3. 利用宏来判断 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main() {
pid_t pid = fork();
if (pid > 0) {
cout << "pid = " << pid << endl;
int status; // 1.定义
int w_pid = wait(&status); // 2.当传出参数
if (WIFEXITED(status)) { // 3.宏判断
cout << "exit value: " << WEXITSTATUS(status) << endl;
}
cout << "sun process is end, w_pid = " << w_pid << endl;
} else if (pid == 0) {
sleep(2);
cout << "(sun)pid = " << getpid() << endl;
}
for (int i = 0; i < 3; i++) {
cout << i << endl;
}
return 10;
}

// exit value: 10

代码:让子进程 sleep 200s,手动 kill 子进程,观察父进程 wait 函数获取到的中止进程的信号的编号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main() {
pid_t pid = fork();
if (pid > 0) {
cout << "pid = " << pid << endl;
int status;
int w_pid = wait(&status);
if (WIFSIGNALED(status)) {
cout << "exit by signal: " << WTERMSIG(status) << endl;
}
cout << "sun process is end, w_pid = " << w_pid << endl;
} else if (pid == 0) {
sleep(2000);
cout << "(sun)pid = " << getpid() << endl;
}
for (int i = 0; i < 3; i++) {
cout << i << endl;
}
return 0;
}

// exit by signal: 9 (因为我手动 kill -9 子进程)

(4)waitpid 函数

作用同 wait 函数。

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);
  • 参数 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)步骤

  1. 父进程打开文件后,fork 子进程。
  2. 此时父子进程均指向同一个文件。
  3. 可以实现父写子读。

(2)实例

注意:先让 子进程 sleep 2s,保证父进程先执行完。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main() {
int fd = open("test.txt", O_RDWR | O_TRUNC | O_CREAT, 0664);
if (fd == -1) {
perror("open file");
exit(1);
}
char p[] = "Hello, world!";
int pid = fork();

if (pid > 0) { // 父进程写文件
write(fd, p, sizeof(p));

} else if ( pid == 0) { // 子进程读文件
sleep(2); // 先让子进程 sleep 2s,保证父进程先写入完。
lseek(fd, 0, SEEK_SET);
read(fd, p, sizeof(p));
}
return 0;
}

10.2 IPC

进程间通信(InterProcess Communication)

IPC 常用的4种方式:

  • 文件

  • 管道:简单

  • 信号:系统开销小

  • 共享映射区:有无血缘关系的进程间通信都可以

  • 本地套接字:稳定

11.管道

匿名管道pipe:没有血缘关系的进程间通信。

我们说的管道大概率是匿名管道,在磁盘中没有对应的磁盘文件。

比如 Linux 命令中 “|” 是匿名管道。

11.1 概念

本质:Linux 内核缓冲区。(伪文件)

伪文件:不在磁盘上,不占用磁盘空间,是内核的一块缓冲区。

进程间通信的原理:父进程创建管道后,fork 子进程,父子进程的文件描述符都会指向 同一个管道的读写两端,从而实现通信。

11.2 特点

  • 两部分:

    • 读端、写端,对应两个文件描述符。
    • 数据写端流入,读端流出。
  • 该缓冲区,内核自动销毁。何时释放?

    • 操作管道的进程被销毁之后,管道自动被释放。
  • 管道默认是阻塞的。

    • 读写操作都阻塞。(父子进程使用 pipe 通信,不需要 sleep)

11.3 内部实现方式

环形队列(循环队列)。

管道缓冲区大小:默认4K。

  • 命令查看默认缓冲区大小:
1
2
ulimit -a
# pipe size
  • 函数查看默认缓冲区大小:
1
2
3
4
5
6
7
8
9
10
11
12
13
// fpathconf 函数
// 第一个参数是文件管道的文件描述符(读端、写端都可以)。
// 第二个参数是 管道缓冲区 的宏。
int main() {
int fd[2];
int ret = pipe(fd);
if (ret == -1) {
perror("pipe error");
exit(1);
}
cout << fpathconf(fd[1], _PC_PIPE_BUF) << endl;
return 0;
}

11.4 局限性

  • 数据只能读取一次,不能重复读取。

  • 管道:半双工,数据传输方向是单向的。读写端均阻塞。

    • 单工:遥控器。
    • 半双工:对讲机。
    • 全双工:电话。
  • 匿名管道:适用于有血缘关系的进程。

11.5 创建匿名管道

创建一个内核缓冲区。

1
2
3
4
#include <fcntl.h>
#include <unistd.h>

int pipe(int pipefd[2]);
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int main() {
int fd[2];
int ret = pipe(fd);
if (ret == -1) {
perror("pipe error");
exit(1);
}
pid_t pid = fork();
if (pid == -1) {
perror("fork error");
exit(1);
}
// 父进程 执行 ps aux
if (pid > 0) {
// 写端操作,关闭读端
close(fd[0]);
// 文件描述符重定向 stdout -> pipe 的写端
dup2(fd[1], STDOUT_FILENO);
// 执行 ps
execlp("ps", "ps", "aux", NULL);
perror("execlp");
}
// 子进程 执行 grep zsh
if (pid == 0) {
// 读端操作,关闭写端
close(fd[1]);
// 文件描述符重定向 stdin -> pipe 的读端
dup2(fd[0], STDIN_FILENO);
// 执行 grep
execlp("grep", "grep", "zsh", NULL);
perror("execlp");
}
return 0;
}

11.7实例:兄弟进程使用管道

实现 ps aux | grep zsh

与 父子进程使用管道 相比,子进程代替了父进程的操作,并需要将父进程的读端写端全部关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
int main() {
int fd[2];
int ret = pipe(fd);
if (ret == -1) {
perror("pipe error");
exit(1);
}
int i = 0;
pid_t pid;
for (i = 0; i < 2; i++) {
pid = fork();
if (pid == 0) {
break;
} else if (pid == -1) {
perror("fork");
exit(-1);
}
}


// 子进程1 关闭读端,重定向 stdin,执行 ps
if (i == 0) {
close(fd[0]);
dup2(fd[1], STDOUT_FILENO);
execlp("ps", "ps", "aux", NULL);
perror("execlp");
}

// 子进程2 关闭写端,重定向 stdout,执行 grep
if (i == 1) {
close(fd[1]);
dup2(fd[0], STDIN_FILENO);
execlp("grep", "grep", "zsh", NULL);
perror("execlp");
}

// 父进程关闭管道 读端写端
if (i == 2) {
close(fd[0]);
close(fd[1]);
int w_pid;
while ((w_pid = wait(NULL)) != -1) {
cout << w_pid << " died!" << endl;
}
}

return 0;
}

11.8 管道的读写行为

  • 读操作
    • 有数据:正常读
    • 无数据
      • 写端全部关闭:read 解除阻塞,返回 0。
      • 写端没有全部关闭:read 阻塞。
  • 写操作
    • 读端全部关闭:管道破裂,内核给当前进程发送信号:13(SIGPIPE),进程被终止。
    • 读端没有全部关闭:
      • 缓冲区写满了:停止写入,等待 read 读走数据。
      • 缓冲区没满:正常写。

(9)设置非阻塞

默认读写两端都阻塞。半双工。

以 设置 读端非阻塞为例。

利用 fcntl 变参函数。功能:复制文件描述符、修改文件的 flags 属性(设置非阻塞使用该功能)。

步骤:

1
2
3
4
5
6
7
8
// 1.获取原来的 flags。
int flags = fcntl(fd[0], F_GETFL);

// 2.设置新的 flags。
flags |= O_NONBLOCK; // flags = flags | O_NONBLOCK;

// 3.修改文件 flags
fcntl(fd[0], F_SETFL, flags);

12.fifo

有名管道:没有血缘关系的进程间通信

12.1 概念

  • 文件类型是 p,表示管道。
  • 实际上也是伪文件(同 pipe),磁盘上的文件大小永远为 0。
  • 在内核中有一个对应的缓冲区。
  • 半双工(同 pipe)
  • 有阻塞行为(同 pipe)

使用场景:没有血缘关系的进程间通信。

12.1 创建方式

  • 命令
1
mkfifo my_fifo
  • 函数
1
2
3
4
#include <sys/types.h>
#include <sys/stat.h>

int mkfifoat(const char* pathname, mode_t mode);

12.3 使用及原理

多个进程,均打开同一个 fifo 文件,获得一个文件描述符,便可以实现读写通信。

写数据进程:

  1. 打开 fifo,只写
1
int fd = open(argv[1], O_WRONLY)
  1. 写入数据 buf
1
write(fd, buf, sizeof(buf));
  1. 关闭文件
1
close(fd);

读数据进程

  1. 打开 fifo,只读
1
int fd = open(argv[1], O_RDONLY)
  1. 读出数据到 buf
1
int len = read(fd, buf, sizeof(buf));
  1. 使用数据:比如把读到的数据写到标准输出文件
1
write(STDOUT_FILENO, buf, len);
  1. 关闭文件
1
close(fd);

12.4 实例:两个程序通过 fifo 通信

fifo_write.cc:每 3 秒向 fifo 写入一次数据。并打印 I wrote!。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void sys_error(const char* str) {
perror(str);
exit(-1);
}

int main(int argc, char **argv) {
if (argc < 2) {
cout << "./a.out fifo_name" << endl;
return -1;
}

// 1. 打开 fifo
int fd = open(argv[1], O_WRONLY);
if (fd < 0) {
sys_error("open");
}

char buf[4096] = {0};
int i = 10;

// 2. 每 3 秒写入一次数据
while(i--) {
sprintf(buf, "number = %d\n", i);
write(fd, buf, sizeof(buf));
cout << "I wrote!" << endl;
sleep(3);
}

// 3. 关闭文件
close(fd);
return 0;
}

fifo_read.cc:fifo 中一有数据,就读到 buf 中,并输出到屏幕上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(int argc, char **argv) {
if (argc < 2) {
cout << "./a.out fifo_name" << endl;
return -1;
}

// 1.打开 fifo
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {
perror("open");
exit(-1);
}

// 2.fifo 中一有数据就读出,并打印
while (1) {
char buf[4096] = {0};
int len = read(fd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, len);
}

// 3.关闭文件
close(fd);
return 0;
}

13.mmap

创建内存映射区。

13.1 概念

mmap 可以将磁盘文件映射到内存中,并返回内存映射区的首地址 ptr,通过 ptr 可以读写内存映射区。

13.2 功能

  • 功能一 – 修改磁盘文件

将磁盘中的文件,映射到虚拟内存中的动态库加载区,直接修改映射区数据即可。

优点:直接在内存中修改数据会更快。

缺点:没有阻塞

  • 功能二 – 进程间通信

基于功能一。父进程在创建内存映射区后,再 fork 子进程,父子进程的内存映射区由同一个磁盘文件映射,便可实现通信。

13.3 函数原型

1
2
3
#include <sys/mman.h>

void *mmap(void *adrr, size_t length, int port, int flags, int fd, off_t offdet);
  • 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 创建内存映射区读写磁盘文件

步骤:

  1. 打开文件
  2. 创建内存映射区
  3. 读写:写数据使用 strcpy,写入时会覆盖。
  4. 释放内存映射区
  5. 关闭文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int main(int argc, const char* argv[]) {

// 1.打开文件
int fd = open("my.txt", O_RDWR);
// int fd = open(argv[1], O_RDWR);
int len = lseek(fd, 0, SEEK_END);

// 2.创建内存映射区
void *ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap");
exit(-1);
}

// 3.读写
cout << (char *)ptr << endl;
strcpy((char *)ptr, "hello, I'm new xxxxx");

// 4.释放内存映射区
int ret = munmap(ptr,len);
if (ret == -1) {
perror("munmap");
exit(-1);
}

// 5.关闭文件
close(fd);

return 0;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
int main(int argc, const char* argv[]) {
// 1.打开文件
int fd = open(argv[1], O_RDWR);
if (fd == -1) {
perror("open");
exit(-1);
}

// 2.创建内存映射区
int len = lseek(fd, 0, SEEK_END);
void* ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap");
exit(-1);
}

// 3.创建子进程
int pid = fork();
if (pid < 0) {
perror("fork");
exit(-1);
}

// 4.父子进程读写数据
if (pid > 0) {
cout << (char*)ptr << endl;

// 写数据
strcpy((char*)ptr, "hello");

// 回收子进程
int w_pid = wait(NULL);
cout << "sun process is end, w_pid = " << w_pid << endl;

} else if (pid == 0) {
sleep(100); // 让父进程先写数据

// 读数据
cout << (char*)ptr << endl;
cout << "(sun)pid = " << getpid() << endl;
}

// 5.释放内存映射区
int ret = munmap(ptr, len);
if (ret == -1) {
perror("munmap error");
exit(-1);
}


// 6.关闭文件
close(fd);
return 0;
}

13.7 实例:没有血缘关系的进程间通信

原理:write、read 进程均通过 mmap 对同一个文件,创建内存映射区,映射到真实的内存中其实是一块区域,从而实现通信。

write.cc:在内存映射区中,每隔一秒,去写入不同的 i。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <sys/types.h>   // open
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h> // perror
#include <stdlib.h> // exit
#include <unistd.h> // read、write
#include <iostream>
#include <string.h>
#include <sys/wait.h> // wait
#include <sys/mman.h> // mmap
using namespace std;

int main(int argc, char ** argv) {
// 1.打开文件
int fd = open(argv[1], O_RDWR | O_CREAT, 0777);
int len = 4096;

// 2.创建内存映射区
void* ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap");
exit(-1);
}

// 3.写内存映射区
int i = 1;
while (1) {
sleep(2);
i++;
cout << "I wrote! Current number is " << i << endl;
char buf[4096] = {0};
sprintf(buf, "number = %d\n", i);
strcpy((char *)ptr, buf);
}

// 4.释放
int ret = munmap(ptr, len);
if (ret == -1) {
perror("munmap");
exit(-1);
}

// 5.关闭文件
close(fd);
return 0;
}

read.cc:在内存映射区中,每隔一秒,打印内存缓冲区的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int main(int argc, char ** argv) {

// 1.打开文件
int fd = open(argv[1], O_RDWR | O_CREAT, 0777);
ftruncate(fd, 4096);
int len = lseek(fd, 0, SEEK_END);

// 2.创建内存映射区
void* ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap");
exit(-1);
}

// 3.读数据
while (1) {
sleep(1);
cout << (char *) ptr << endl;
}

// 4.释放
int ret = munmap(ptr, len);
if (ret == -1) {
perror("munmap");
exit(-1);
}

// 5.关闭文件
close(fd);

return 0;
}

13.8 munmap

释放内存映射区

1
int munmap(void *addr, size_t length);
  • 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
man signal
信号名称编号动作事件
SIGHUP1A在控制终端上是挂起信号, 或者控制进程结束
SIGINT2A从键盘输入的中断
SIGQUIT3C从键盘输入的退出
SIGILL4C无效硬件指令
SIGABRT6C非正常终止, 可能来自 abort(3)
SIGFPE8C浮点运算例外
SIGKILL9AEF杀死进程信号
SIGSEGV11C无效的内存引用
SIGPIPE13A管道中止: 写入无人读取的管道
SIGALRM14A来自 alarm(2) 的超时信号
SIGTERM15A终止信号
SIGUSR130,10,16A用户定义的信号 1
SIGUSR231,12,17A用户定义的信号 2
SIGCHLD20,17,18B子进程结束或停止
SIGCONT19,18,25继续停止的进程
SIGSTOP17,19,23DEF停止进程
SIGTSTP18,20,24D终端上发出的停止信号
SIGTTIN21,21,26D后台进程试图从控制终端(tty)输入
SIGTTOU22,22,27D后台进程试图在控制终端(tty)输出
动作说明
A终止进程,Term
B忽略这个信号,Ign
C终止进程, 并且核心转储,Core
D暂停进程,Stop
E信号不能被捕获
F信号不能被忽略

PS:一个信号有多个值,是因为平台不一样,x86平台使用中间这列的值。

注意:SIGKILL 和 SIGSTOP 不能被捕捉(caught)、不能被阻塞(blocked)、不能被忽略(ignored)。

14.6 kill 函数

发信号给指定进程。

(1)函数原型

1
2
#include <signal>
int kill(pid_t pid, int sig);
  • pid:
    • pid > 0:发送信号给指定的进程。
    • pid == 0:发送信号给与调用 kill 函数进程属于同一进程组的所有进程。
    • pid < -1:发送信号给 pgid == -pid 的进程组。
    • pid == -1:发送给进程有权限发送的系统中所有的进程。
      • 举个例子:Test 用户有权限发给 Test 用户的所有进程,但是没有权限发给 root 用户的进程。
  • sig:可以使用数字或宏,推荐使用宏。
  • 返回值:成功返回 0,出错返回 -1。

(2)查看有哪些信号

1
kill -l

(3)实例:杀死父进程

子进程1秒后杀死父进程,父进程循环打印 i。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(-1);
}

// 子进程杀死父进程
if (pid == 0) {
sleep(1);
kill(getppid(), SIGKILL);
}

// 父进程一直工作
if (pid > 0) {
int i = 0;
while(1) {
i++;
cout << "number = " << i << endl;
}
}
return 0;
}

14.7 raise 函数

自己给自己发信号。

(1)函数原型

1
2
3
4
5
#include <signal.h>

int raise(int sig);
// kill 函数也可以实现自己给自己发送信号。
int kill(getpid(), int sig);
  • 返回值:成功返回 0,出错返回 -1。

(2)实例

子进程给自己发送 SIGKILL,让父进程来回收子进程,并检测子进程因为什么信号而终止的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(-1);
}

// 子进程使用 raise
if (pid == 0) {
raise(SIGKILL);
}

// 父进程回收子进程,并识别杀死子进程的信号
if (pid > 0) {
int status;
int w_pid = wait(&status);
cout << "pid = " << w_pid << endl;
if (WIFSIGNALED(status)) {
cout << "signal = " << WTERMSIG(status) << endl;
}
}
return 0;
}

14.8 abort 函数

给自己发送异常终止信号。

abort:v.流产 n.中止计划

(1)函数原型

1
2
3
#include <signal.h>

void abort(void);
  • 没有返回值、没有参数
  • 永远不会调用失败

(2)功能

给自己发送 SIGABRT 信号,终止进程,并产生 core 文件。

(3)实例

子进程调用 abort 函数,让父进程来回收子进程,并检测子进程因为什么信号而终止的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(-1);
}

// 子进程使用 abort
if (pid == 0) {
abort();
}

// 父进程回收子进程,并识别杀死子进程的信号
if (pid > 0) {
int status;
int w_pid = wait(&status);
cout << "pid = " << w_pid << endl;
if (WIFSIGNALED(status)) {
cout << "signal = " << WTERMSIG(status) << endl;
}
}
return 0;
}

14.9 alarm 函数

定时器超时,终止进程。

(1)函数原型

1
2
3
#include <unistd.h>

unsigned int alarm(unsigned int seconds);
  • seconds:多少秒之后发送信号。

    • 如果 seconds 被重置为 0,则意味着函数被取消了,不会终止进程。
  • 返回值:上次倒计时还有剩余多少秒。

    再次调用 alarm 会重置的 alarm 的倒计时数,上一次 alarm 函数会失效,会重新开始倒计时。【每个进程只有一个定时器】

    1
    2
    3
    4
    int 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main() {
// 5 秒后会终止进程。
alarm(5);

// 每一秒打印一次 i
int i = 5;
while(--i) {
printf("i = %d\n", i);
sleep(1);
}

// 重置 alarm 函数倒计时,重新开始倒计时。
// 再次调用 alarm 会重置的 alarm 的倒计时数,上一次 alarm 函数会失效,会重新开始倒计时。
int b = alarm(6);
printf("b = %d i = %d\n", b, i);

// 每一秒打印一次 i
i = 0;
while (++i) {
printf("i = %d\n", i);
sleep(1);
}
return 0;
}

(3)特点

  • 每个进程只有一个定时器。

  • 定时器使用的是 自然定时法

    不受进程状态影响。即使进程在进行复杂的算法或者卡顿,也不影响倒计时,互相独立。

(4)实例:测试计算机一秒钟能数多少数

1
2
3
4
5
6
7
8
int main() {
alarm(1);
int i = 0;
while (1) {
printf("%d\n", ++i);
}
return 0;
}

使用 time ./a.out 可以查看程序运行时间,其实实际上数数的时间少于一秒,因为内核需要时间。

1
2
3
time ./a.out > 1.txt 
# [2] 7101 alarm ./a.out > 1.txt
# ./a.out > 1.txt 0.78s user 0.19s system 96% cpu 1.006 total
  • 真实运行时间 = 用户 user + 内核 system + 损耗

  • 真实运行时间 < 1s,原因:损耗来自于文件 IO 操作。

14.10 setitimer 函数

定时器,并实现周期性定时

(1)函数原型

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <sys/time.h>

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

struct itimerval {
struct timeval it_interval; // 定时器循环周期
struct timeval it_value; // 第一次触发定时器的时间
};

struct timerval {
time_t tv_sec; // 秒
suseconds_t tv_usec; // 微秒
};
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {

// 第二个参数
struct itimerval s;

// 第一次触发定时器的时间:2s
s.it_value.tv_sec = 2; //s
s.it_value.tv_usec = 0; //us

// 定时器循环周期:每 5s 循环一次。
s.it_interval.tv_sec = 5; // s
s.it_interval.tv_usec = 0; // us

//倒计时 2 s
setitimer(ITIMER_REAL, &s, NULL);
int i = 0;
while(1) {
cout << "i = " << i << endl;
i++;
sleep(1);
}
return 0;
}

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
int sigemptyset(sigset_t *set);
  • sigset_t:阻塞信号集、未决信号集、自定义信号集的类型。

  • sigset_t *set:集合的地址。

    1
    2
    sigset_t my_set;   // 注意:定义的信号集,初始值是随机的,需要用 sigemptyset 置空。
    sigemptyset(&myset); // 取地址

(2)将所有信号加入 set 集合

相当于说,该集合中所有信号标志位为 1。(SIG_BLOCK情况下)

1
int sigfillset(sigset_t *set);

(3)将 signo 信号加入到 set 集合

相当于说,signo 信号的标志位为 1。(SIG_BLOCK情况下)

1
int sigaddset(sigset_t *set, int signo);
  • int signo:表示信号的编号,例如:SIGINT 编号为 2;SIGKILL 编号为 9。

(4)从 set 集合中移除 signo 信号

1
int sigdelset(sigset_t *set, int signo);

(5)判断信号是否存在

该函数可以判断阻塞信号集、未决信号集、自定义信号集。

1
int sigismember(const sigset_t *set, int signo);
  • 返回值:存在返回 1,不存在返回 0。

14.13 设置阻塞信号集

将自定义信号集设置给阻塞信号集。

1
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • how:

    • SIG_BLOCK:

      阻塞,自定义信号集中,有哪些信号,阻塞信号集中就会阻塞这些信号。

      mask = mask | my_set

    • SIG_UNBLOCK:

      解除阻塞,自定义信号集中,有哪些信号,阻塞信号集就会对这些信号解除阻塞。

      mask = mask & (~set)

    • SIG_SETMASK:

      覆盖阻塞信号集。

      mask = set

  • sigset_t *oldset:传出参数,更改之前的阻塞信号集的状态。(不关心的话可以写 NULL)

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main() {

// 1.定义一个自定义集合
sigset_t my_set;

// 2.注意:自定义的集合的值是随机的,需要使用 sigemptyset 置空!
sigemptyset(&my_set);

// 3.手动设置自定义集合中阻塞信号
sigaddset(&my_set, SIGINT);
sigaddset(&my_set, SIGQUIT);
sigaddset(&my_set, SIGKILL);
// 4.将自定义信号集传递给阻塞信号集
sigprocmask(SIG_BLOCK, &my_set, NULL);
return 0;
}

14.14 读取当前进程的未决信号集

1
int sigpending(sigset_t *set);
  • sigset_t *set:传出参数,内核将未决信号集写入 set。
  • 将传出参数 set 做 sigismember 的参数,即可知道信号是否在未决信号集中(即未被处理)。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
// 自定一个信号集 penset
sigset_t pendset;

// 将未决信号集传出到 penset
sigpending(&pendset);

// 判断信号 1-50 是否存在于 penset
for (int i = 1; i < 51; i++) {
cout << sigismember(&pendset, i);
}

return 0;
}

14.15 信号捕捉 signal

给某一个进程的某一个特定信号(标号为 SIGINT)注册一个相应的处理函数,即对该信号的默认处理动作进行修改,修改为 handler 函数所指向的方式。

(1)函数原型

1
2
typedef void (*sighandler_t)(int);   // 函数指针声明
sighandler_t signal(int signum, sighandler_t handler); // 捕捉信号
  • signum:要捕捉的信号。
  • sighandler_t handler:回调函数,自己实现。当捕捉到 signum,就会执行。

(2)实例:捕捉 crtl + c 信号 SIGINT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 处理事件
void my_func(int number) {
cout << "catch your signal: " << number << endl;
return;
}

int main() {
signal(SIGINT, my_func); // 捕捉信号

// 保证进程一直在运行,不终止。
while (1) {
cout << "hello" << endl;
sleep(2);
}
return 0;
}

14.16 信号捕捉 sigaction

同 signal。

(1)函数原型

1
2
3
4
5
6
7
8
9
int sigaction(int signum, const struct sigaction, struct sigaction *oldact);

struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t*, void*); // 一般不会用,忽略,不需要初始化。
sigset_t sa_mask; // 在信号处理函数 sa_handler 执行过程中,临时屏蔽指定的信号。
int sa_flags;
void (*sa_restorer)(void); // 已废弃。
};
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 处理事件
void my_func(int number) {
cout << "catch your signal: " << number << endl;
sleep(3);
cout << "weak up !" << endl;
return;
}

int main() {

// 创建 结构体参数
struct sigaction act;

// 设置信号新的处理函数
act.sa_flags = 0;
act.sa_handler= my_func;

// 设置临时屏蔽的信号
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT);

// 捕捉信号
sigaction(SIGINT, &act, NULL);

// 保证进程一直在运行,不终止。
while (1) {
cout << "hello" << endl;
sleep(1);
}
return 0;
}

14.17 内核实现信号捕捉的过程

  1. 用户区】在执行主控制流程的某条指令时,因为中断、异常、系统调用而进入内核。

    如果执行一些变量赋值、if 判断、循环语句不会进入内核区。

  2. 内核区】内核处理完异常,准备回用户模式之前,先处理当前进程中可以可以递达的信号。

    何为可递达?没有被阻塞的未决信号。见关系

  3. 用户区】如果信号的处理动作是自定义的信号处理函数,则回到用户模式,执行信号处理函数。(而不是回到主控制流程)

  4. 内核区】信号处理函数返回时,会执行特殊的系统调用 sigreturn,再次进入内核。

    为什么需要再次进入内核,因为内核调用的信号处理函数,函数执行完之后需要返回。

  5. 用户区】返回用户模式,从主控制流程中,上次中断的地方继续向下执行。

14.18 慢速系统调用中断

系统调用可以分成两类:

  • 慢速系统调用:可能会使进程永远阻塞的一类。如果在阻塞期间收到一个信号,该系统调用被中断,不再继续执行。中断后返回 -1,设置 errno 为 EINTR(表示:信号中断)
  • 其他系统调用。

15.守护进程

15.1 特点

  • 后台服务进程
  • 独立与控制终端
  • 周期性执行某任务
  • 不受用户登陆注销的影响
  • 一般采用以 d 结尾的名字(服务)

15.2 进程组

  • 进程组的组长:组里面的第一个进程。
  • 进程组的 ID:和进程组组长的 ID 相同。

15.3 会话

多个进程组组成会话。

(1)创建会话

先fork,父进程死,儿子执行创建会话操作(setsid)

(2)注意事项

  • 不能由进程组组长来创建会话。

  • 创建会话的进程会成为新的进程组组长。

  • 创建出的新会话会丢弃原有的控制终端。

    PS:创建会话后,子进程便是守护进程,不受用户登陆注销的影响。

(3)实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
// 创建子进程
pid_t pid = fork();
if (pid > 0) {
// 父进程杀死
kill(getpid(), SIGKILL);
} else if (pid == 0) {
// 子进程创建会话
setsid();
// 保证子进程不结束,以便观察结果
while(1);
}
return 0;
}

15.4 创建守护进程

  1. fork 子进程,父进程退出

  2. 子进程创建新会话

    • setsid()
  3. 改变当前工作目录(不必须,增强程序健壮性)

    避免当前工作目录被卸载。

    举个例子:插一个 U 盘,a.out,在 U 盘目录中启动 a.out。a.out 启动过程中,U 盘拔掉了。

  4. 重设文件掩码(不必须,增加子进程的灵活性)

    • 子进程会继承父进程的掩码。
    • umask(0):没有限制,0 取反就是 777。
  5. 关闭文件描述符

    • close(0)
    • close(1)
    • close(2)
    • 因为不需要终端,释放资源。
  6. 执行核心操作

实例:创建守护进程

创建会话,设置定时器,并捕捉该信号,调用回调函数,将时间写入文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 回调函数 -- 将当前时间写入文件。
void doing(int no) {
// 获取当前时间
time_t cur;
time(&cur);
// 格式化时间
char *ptr = ctime(&cur);
// 写入文件
int fd = open("/root/process/time.txt", O_CREAT | O_WRONLY | O_APPEND, 0777);
write(fd, ptr, strlen(ptr));
close(fd);
return;
}

int main() {
pid_t pid = fork();
if (pid > 0) {
// 1.父进程退出
kill(getpid(), SIGKILL);
} else if (pid == 0) {
// 2.子进程创建会话
setsid();

// 3.改变当前工作目录
chdir("/home");

// 4.重置文件掩码
umask(0);

// 5.关闭文件描述符
close(0);
close(1);
close(2);

// 6.执行核心操作--设定定时器,捕捉定时器信号
// 注册信号捕捉
struct sigaction act;
act.sa_flags = 0;
act.sa_handler = doing;
sigemptyset(&act.sa_mask);
sigaction(SIGALRM, &act, NULL);

struct itimerval val;
// 第一次执行的时间
val.it_value.tv_usec = 0;
val.it_value.tv_sec = 2;
// 循环的周期
val.it_interval.tv_usec = 0;
val.it_interval.tv_sec = 1;
setitimer(ITIMER_REAL, &val, NULL);

while(1);
}
return 0;
}

16.线程

16.1 特点

  • 创建线程后,地址空间没有变化,子线程和主线程共用地址空间。

  • 进程退出成了线程(主线程)。

  • 主线程和子线程有各自独立的 PCB。

  • 子线程的 PCB 是从主线程拷贝过来的。

16.2 共享与通信

(1)共享:

  • 环境变量
  • 命令行参数
  • 动态库
  • bss
  • data
  • text

(2)不共享:

  • 举个例子:一共有 5 个线程,栈区被平均分为 5 份。
  • 保证每个线程都有自己独立的栈空间,栈区变量不会冲突。

(3)通信:全局变量、堆。

16.3 Linux 和 Windows

Linux 和 windows 的实现原理不一样。

最开始,Linux 中没有线程,后由进程更改而来的。

在 Linux 中:

  • 线程就是进程,轻量级进程。
  • 对于内核,线程就是进程。只认 CPB。

16.4 查看线程的 LWP 号

  • 线程号(LWP号)和线程 ID是有区别的。

  • 线程号(LWP号)是给内核看的。

  • 线程 ID 是给用户看的。

如何查看?

  1. 找到进程 ID。

pd -LF 进程ID 即可以查看 LWP号。

16.5 多线程和多进程的区别

(1)多进程始终共享的资源:

  • 代码
  • 文件描述符
  • 内存映射区

(2)多线程始终共享的资源:

  • 全局变量

(3)使用线程的好处:

  • 节省资源(线程共用虚拟地址空间)
  • 不会降低程序效率(CPU 按照 PCB 来分配时间片)

16.6 创建线程

(1)函数原型

1
2
3
4
5
6
7
#include <pthread.h>

int pthread_create(pthread_t *thread, // 线程 ID,无符号长整型
const pthread_attr_t *arr, // 线程属性,NULL
void *(*start_routine)(void*) // 线程处理函数
void *arg; // 线程处理函数参数
)
  • 返回值:成功返回 0,失败返回错误号。(线程库里不能使用 perror 打印。)
  • const pthread_attr_t *arr:传出参数:无符号长整型。(一般七八位)
  • const pthread_attr_t *arr:线程属性
    • 传 NULL,默认父子线程不分离。
    • 重要的属性——线程分离:子线程需要父线程去回收,线程分离之后,子线程就可以自己回收自己的线程。
  • void (start_routine)(void*):函数指针。函数的作用:创建的子线程需要执行的函数;如果不指定,就不执行任何操作。
  • void *arg:线程处理函数的参数。

(2)实例:创建线程

  • 编译的时候,要用 ``-l` 指定 pthread 库。
1
g++ ../src/pthread_create.cc -lpthread
  • 父子线程共用地址空间,一旦父线程,执行完毕,销毁地址空间,子线程就有可能来不及执行。

    所以需要在程序结束前,让父线程 sleep 一下,让子线程抢到 CPU。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 线程处理函数
void* my_fun(void *arg) {
// 打印线程id
cout << "child thread id: " << pthread_self() << endl;
return NULL;
}

int main() {
pthread_t pth_id; // 传出线程 ID
pthread_create(&pth_id, NULL, my_fun, NULL); // 创建线程

for (int i = 0; i < 5; i++) {
cout << "i = " << i << endl;
}

sleep(2); // 防止父线程先执行完毕,销毁地址空间。
return 0;
}

(3)实例:循环创建多个线程

用线程数组来储存创建线程的 ID。

并将 i 的地址当作参数传入,在线程处理函数中打印。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 线程处理函数
void* my_fun(void *arg) {
int num = *(int *)arg;
cout << "child thread id: " << pthread_self() << " num = " << num << endl;
return NULL;
}

int main() {
pthread_t pth_id[5]; // 传出线程 ID

for (int i = 0; i < 5; i++) {
pthread_create(&pth_id[i], NULL, my_fun, (void *)&i);
cout << "i = " << i << endl;
}
sleep(2);
return 0;
}

(4)重要问题:

  • 打印结果:
1
2
3
4
5
6
7
8
9
10
i = 0
i = 1
i = 2
i = 3
i = 4
child thread id: 140608273958656 num = 5
child thread id: 140608265565952 num = 5
child thread id: 140608299136768 num = 5
child thread id: 140608290744064 num = 5
child thread id: 140608282351360 num = 5
  • 问题:num 并没有按照传入的参数改变。

  • 原因:线程处理函数执行到一半的时候,CPU 资源被抢走,等到有 CPU 资源的时候,线程去 (int *)arg 地址中取值;此时,i 的值已经在主进程中发生了改变。

16.7 线程打印错误信息

线程不可以直接打印错误信息,需要拿到错误号,再来获取错误信息。

1
2
3
#include <string.h>

char *strerror(ret); // ret 是 pthread_create 的返回值。

实例:线程打印错误信息

1
2
3
4
5
6
7
8
9
int main() {
pthread_t pth_id;
int ret = pthread_create(&pth_id, NULL, my_fun, NULL);
if (ret != 0) {
cout << strerror(ret) << endl; // 获取错误信息
}
sleep(2);
return 0;
}

16.8 pthread_exit 函数

当前线程退出,但是不影响其他线程的执行。

(1)与 exit 的区别

  • 任何线程,只要调用 exit,整个进程都会结束。

  • 而 pthread_exit 只会结束线程,不会回收地址空间。

(2)函数原型

1
void pthread_exit(void *retval);
  • void *retval
    • (传出参数)一个指针,指向一个地址。可以将一些数据传出。
    • 不可以使用栈地址,线程结束,该线程的栈地址就没了。(只能使用全局、堆)
    • 如果没什么要传出的,可以使用 NULL。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void* my_fun(void *arg) {
cout << "child thread id: " << pthread_self() << endl;

sleep(1); // 让父进程抢到 CPU 执行 pthread_exit。验证:不影响子进程。

for (int i = 0; i < 5; i++) {
cout << "child i = " << i << endl;
}
return NULL;
}

int main() {
pthread_t pth_id;
int ret = pthread_create(&pth_id, NULL, my_fun, NULL);
cout << "parent thread id: " << pthread_self() << endl;

pthread_exit(NULL); // 父进程退出,以下代码不执行。

for (int i = 0; i < 5; i++) {
cout << "parent i = " << i << endl;
}
return 0;
}

16.9 阻塞等待子线程的退出

和回收进程的 wait 相似。

子线程的 PCB 需要父线程来回收,pthread_join 可以阻塞等待子线程结束。

(1)函数原型

1
int pthread_join(pthread_t thread, void **retval);
  • thread:要回收的子线程的线程 ID。
  • retval
    • 传出参数,需要定义二级指针,并传入地址。
    • 二级指针,读取线程退出的时候携带的状态信息。
    • retval 指向内存和 pthread_exit 参数指向同一块内存地址。

(2)实例:父线程回收子线程

子线程睡眠,父进程等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int num = 10;
void *my_fun(void *) {
cout << "child pthread id is " << pthread_self() << endl;
sleep(2);
pthread_exit(&num);
return NULL;
}

int main() {
pthread_t pth_id;
pthread_create(&pth_id, NULL, my_fun, NULL);
void *ptr;

// 父进程会在此处阻塞,等待子进程退出。
int ret = pthread_join(pth_id, &ptr);

// 成功回收返回 0
cout << "ret = " << ret << endl;

// 此处打印 pthread_exit 的参数。(使用栈会出现随机值,只能使用全局变量,或者堆内存。)
cout << "*ptr = " << *(int *)ptr << endl;

// 检测阻塞效果
for (int i = 0; i < 3; i++) {
cout << "i = " << i << endl;
}
return 0;
}

16.10 线程分离

(1)pthread_detach 函数

创建线程后,实现线程分离。

1
int pthread_detach(pthread_t thread);
  • 调用 pthread_deach 不需要 pthread_join。
  • 子线程会自动回收自己的 pcb。

(2)创建的时候设置线程分离属性

步骤:

  1. 定义线程属性类型
1
pthread_attr_t attr;
  1. 对线程属性变量初始化
1
int pthread_attr_init(pthread_attr_t *attr);
  1. 设置线程分离属性
1
2
int pthread_attr_setdetachstate(pthread_attr_t* attr, int detachstate);
// set detach state // 设置 分离 状态
  • attr:传出参数,该函数将 detachstate 写入 attr (线程属性)中。
  • detachstate:
    • PTHREAD_CREATE_DETACHED:分离
    • PTHREAD_CREATE_JOINABLE:非分离
  1. 释放线程资源
1
int pthread_attr_destroy(pthread_attr_t *attr);

16.11 取消线程

1
int pthread_cancel(pthread_t thread);
  • 在要杀死的子进程的对应处理函数的内部,发生过系统调用的地方叫取消点。
  • 有取消点,才可以调用 pthread_cancel。
  • 补充:什么叫系统调用?write、printf 这些最后均会调用系统函数。

PS:如果只是一些定义和赋值,可以在函数内部,写上 pthread_testcancel 函数。

16.12 判断两个线程相等

因为 pthread_id 是长整型,可以直接比较,这个函数暂且用不上。

1
int pthread_equl(pthread_t t1, pthread_t t2);

17.线程同步

17.1 为什么需要同步

数据的流向是 内存/缓存 – 寄存器 – CPU。也就是说,num变量进行计算需要三步:

  1. 从内存/缓存中拷贝到寄存器
  2. CPU 在寄存器中运算
  3. 从寄存器拷贝回内存/缓存。

如果实际线程运行时,谁抢到 CPU 谁来执行,所以运行顺序不一定,可能会出现线程指令交叉执行的情况。

如下图,其实完成了两次 num++,但是最后两次复制指令一起执行,覆盖了数据。

代码如下:两个线程分别让 num 自增一千万次,如果最后 num 不等于两千万,则说明线程不同步产生了问题。

PS:应保证大数据量,并多次运行,才更有可能出现问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int num = 0;
void* Func_a(void *) {
for (int i = 0; i < 10000000; i++) {
num++;
}
return NULL;
}

void* Func_b(void *) {
for (int i = 0; i < 10000000; i++) {
num++;
}
return NULL;
}

int main() {
pthread_t pth_a, pth_b;
pthread_create(&pth_b, NULL, Func_b, NULL);
pthread_create(&pth_a, NULL, Func_a, NULL);
pthread_join(pth_a, NULL);
pthread_join(pth_b, NULL);
cout << "num = " << num << endl;
return 0;
}

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
3
4
5
加锁语句1

/* 代码:读写共享资源 */

解锁语句1

线程 2:

1
2
3
4
5
加锁语句2

/* 代码:读写共享资源 */

解锁语句2

过程分析:

当线程 1 拥有 CPU,开始执行,执行到 加锁语句 1,检查 mutex 等于几。

  • 如果 mutex == 1,继续执行 读写共享资源,同时让 mutex = 0。
  • 如果 mutex == 0,阻塞在此,等待 mutex == 1 时,再执行下面的代码。

当线程 2 抢到 CPU,开始执行,执行到 加锁语句 2,检查 mutex 等于几。

发现 mutex == 0,阻塞在此,放弃 CPU。

18.4 步骤

  1. 创建互斥锁
1
pthread_mutex_t mutex;
  1. 初始化互斥锁
1
2
3
4
5
6
pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr
);

// 静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  • restrict:表示 mutex 所指向的内存空间,只能由 mutex 来访问。其他的指针就是等于 mutex,也不能访问。(对函数使用没有影响)
  • attr:线程锁的属性,填 NULL 即可。
  • 初始化后,mutex == 1,加锁后,mutex == 0。一直在 0 和 1之前徘徊。【互斥锁的原理
  1. 加锁

如果加锁成功,mutex 从 1 变为 0。

1
2
// 阻塞锁
pthread_mutex_lock(pthread_mutex_t *mutex);
  • 临界资源没被上锁 — 当前线程上锁。
  • 临界资源已经上锁 — 当前线程阻塞,锁被打开的时候,线程解除阻塞。
1
2
// 不会阻塞的锁
pthread_mutex_trylock(pthread_mutex_t *mutex);
  • 临界资源没被上锁 — 当前线程上锁,返回 0。
  • 临界资源已经上锁 — 加锁失败,返回错误号,不阻塞。(通过对返回值的判断,来决定执行什么操作)
  1. 解锁

在解锁的同时,会唤醒阻塞在该锁上的所有线程。mutex 从 0 变为 1。

1
pthread_mutex_unlock(pthread_mutex_t *mutex);
  1. 销毁互斥锁/释放资源
1
pthread_mutex_destroy(pthread_mutex_t *mutex);

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 1.创建互斥锁
pthread_mutex_t mutex;
int num = 0;

void* func_a(void *) {
for (int i = 0; i < 100000; i++) {

// 3.加锁
pthread_mutex_lock(&mutex);
num++;
std::cout << "A" << std::endl;
// 4.解锁
pthread_mutex_unlock(&mutex);
}
return NULL;
}

void* func_b(void *) {
for (int i = 0; i < 100000; i++) {

// 3.加锁
pthread_mutex_lock(&mutex);
num++;
std::cout << "B" << std::endl;
// 4.解锁
pthread_mutex_unlock(&mutex);
}
return NULL;
}

int main() {
// 2.初始化互斥锁
pthread_mutex_init(&mutex, NULL);

pthread_t pth_a, pth_b;
pthread_create(&pth_a, NULL,func_a, NULL);
pthread_create(&pth_b, NULL,func_b, NULL);
pthread_join(pth_a, NULL);
pthread_join(pth_b, NULL);

// 5.销毁互斥锁
pthread_mutex_destroy(&mutex);
std::cout << "num = " << num << std::endl;
return 0;
}

19.死锁

19.1 造成死锁的原因

  • 自己锁自己
1
2
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex); // 因为已经加锁,此函数阻塞在这里
  • 加锁后未解开,其他线程阻塞
1
2
3
4
5
6
7
// func_b()
pthread_mutex_lock(&mutex); // 只加锁为解锁

// func_a()
pthread_mutex_lock(&mutex); // 阻塞在此
//临界区代码
pthread_mutex_unlock(&mutex);
  • 竞争临界资源
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
pthread_rwlock_t rwlock;
  1. 初始化读写锁
1
2
3
pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr
);
  • restrict:表示 rwlock 所指向的内存空间,只能由 rwlock 来访问。其他的指针就是等于 rwlock,也不能访问。(对函数使用没有影响)
  • attr:线程锁的属性,填 NULL 即可。
  1. 加读锁
1
2
// 阻塞锁
pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
  • 临界资源无写锁 — 当前线程上锁。
  • 临界资源有写锁 — 当前线程阻塞,锁被打开的时候,线程解除阻塞。
1
2
// 不会阻塞的锁
pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
  • 临界资源无写锁 — 当前线程上锁,返回 0。
  • 临界资源有写锁 — 加锁失败,返回错误号,不阻塞。(通过对返回值的判断,来决定执行什么操作)
  1. 加写锁
1
2
// 阻塞锁
pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
  • 临界资源无锁 — 当前线程上锁。
  • 临界资源有读锁或者写锁 — 当前线程阻塞,锁被打开的时候,线程解除阻塞。
1
2
// 不会阻塞的锁
pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
  • 临界资源无写锁 — 当前线程上锁,返回 0。
  • 临界资源有写锁 — 加锁失败,返回错误号,不阻塞。(通过对返回值的判断,来决定执行什么操作)
  1. 解锁
1
pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
  1. 销毁读写锁锁/释放资源
1
pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

20.5 实例

竞争资源:number。

3 个写线程,5 个读线程对 number 进行读写操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 1.创建读写锁
pthread_rwlock_t rwlock;

int number = 0;
int count = 100000;
void *write_func(void* arg) {
while (count--) {
// 3.加写锁
pthread_rwlock_wrlock(&rwlock);
number++;
std::cout << "=======write: " << number << std::endl;

// 4.解锁
pthread_rwlock_unlock(&rwlock);
usleep(5);
}
return NULL;
}

void *read_func(void* arg) {
while (count--) {
// 3.加读锁
pthread_rwlock_wrlock(&rwlock);
std::cout << "=======read: " << number << std::endl;

// 4.解锁
pthread_rwlock_unlock(&rwlock);
usleep(5);
}
return NULL;
}

int main() {
// 2.初始化读写锁
pthread_rwlock_init(&rwlock, NULL);

pthread_t pth[8];

// 创建 3 个写线程
for (int i = 0; i < 3; i++) {
pthread_create(&pth[i], NULL, write_func, NULL);
}

// 创建 5 个读线程
for (int i = 0; i < 5; i++) {
pthread_create(&pth[i], NULL, read_func, NULL);
}

// 阻塞回收子线程的 PCB
for (int i = 0; i < 8; i++) {
pthread_join(pth[i], NULL);
}

// 5.回收资源
pthread_rwlock_destroy(&rwlock);
return 0;
}

##21.条件变量

不是锁,在不满足条件时,阻塞线程。

21.1 为什么需要条件变量

因为互斥锁没办法做到阻塞线程,它只能保证共享资源操作的原子性。

条件变量 + 互斥锁一起使用:

  • 条件变量:不满足条件则阻塞
  • 互斥锁:保护共享资源

21.2 条件变量的两个动作

  • 条件不满足,阻塞线程。
  • 条件满足,通知阻塞的线程开始工作。

21.3 条件变量的类型

condtion:条件

类型:pthread_cond_t。

21.4 步骤

  1. 创建条件变量
1
pthread_cond_t cond;
  1. 初始化条件变量
1
2
3
pthread_cond_inti(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr
);
  • attr:条件变量的属性,填 NULL 即可。
  1. 阻塞等待一个条件变量
1
2
3
pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex
)
  • 功能:
  1. 限时等待一个条件变量

非永久阻塞,阻塞一定的时长。

1
2
3
4
pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime
);
  • abstime:阻塞时长,绝对时间。
  1. 唤醒 至少一个阻塞在条件变量上的 线程
1
pthread_cond_signal(pthread_cond_t *cond);
  1. 唤醒 全部阻塞在条件变量上的 线程
1
pthread_cond_broadcast(pthread_cond_t *cond);
  1. 销毁条件变量/释放资源
1
pthread_cond_destroy(pthread_cond_t *cond);

21.5 生产者消费者思路

生产者线程

1
2
3
4
5
6
7
8
9
10
// 一直生产
while (1) {
/* 代码:生产产品 */

pthread_mutex_lock(&mutex);
/* 代码:将产品加入缓冲区 */
pthread_mutex_unlock(&mutex);

pthread_cond_signal(pthread_cond_t *cond); // 唤醒消费者
}

消费者线程

1
2
3
4
5
6
7
8
9
10
11
12
// 一直消费
while (1) {

pthread_mutex_lock(&mutex); // 因为要访问 buffer,所以要加锁
if (buffer == NULL) { // 判断缓冲区是否有产品可以消费
pthread_cond_wait(&cond, &mutex); // 阻塞,等待生产者生产产品后唤醒
}

/* 代码:消费产品 */

pthread_mutex_unlock(&mutex);
}

重要问题:

  • 当消费者发现 buffer 为空时,会阻塞等待,生产者生产。

  • 问题在于:此时 buffer 已经被消费者加锁了,生产者会阻塞在 将产品加入缓冲区 的位置。

不用担心,因为加锁 buffer 后,条件变量发生了阻塞,此时会解开互斥锁;等到条件变量不再阻塞时,再锁上互斥锁。

21.6 实例:生产者消费者模型

利用 互斥锁 + 条件变量 实现生产者消费者模型。

  • 条件变量:不满足条件则阻塞
  • 互斥锁:保护共享资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
struct Node {
Node* next;
int data;
Node(Node *ptr, int number) : next(ptr), data(number) {}
};

// 定义缓冲区 buffer
Node* head = NULL;

// 定义互斥锁 + 条件变量
pthread_mutex_t mutex;
pthread_cond_t cond;

void* producer(void *) {
while (1) {

// 生产
srand((unsigned)time(NULL));
pthread_mutex_lock(&mutex); // 加锁
Node *tmp = new Node(head, rand() % 1000);
head = tmp;
std::cout << "++++++++++producer: " << head->data << "\t" << pthread_self() << std::endl;
pthread_mutex_unlock(&mutex); // 解锁
pthread_cond_signal(&cond); // 唤醒条件变量的阻塞
sleep(rand() % 3);
}
return NULL;
}

void* customer(void *) {
while (1) {

// 消费
pthread_mutex_lock(&mutex); // 加锁
if (head == NULL) {
pthread_cond_wait(&cond, &mutex); // 条件变量阻塞
}
Node* tmp = head;
head = head->next;
std::cout << "----------customer: " << tmp->data << "\t" << pthread_self() << std::endl;
pthread_mutex_unlock(&mutex); // 解锁
delete tmp;
sleep(rand() % 3);
}
return NULL;
}

int main() {

// 初始化互斥锁 + 条件变量
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond, NULL);

// 创建生产者和消费者
pthread_t pth1, pth2;
pthread_create(&pth2, NULL, customer, NULL);
pthread_create(&pth1, NULL, producer, NULL);

// 阻塞回收子线程
pthread_join(pth1, NULL);
pthread_join(pth2, NULL);

// 释放资源
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);

return 0;
}

22.信号量

22.1 概念

头文件:<semaphore.h>

类型:sem_t

作用:加强版互斥锁

区别

  • 因为 mutex 在 0 和 1 之前徘徊,它实现的同步都是串行的。
  • 信号量类似于封装了多把互斥锁,从而实现并行。

举个例子

  • 使用互斥锁:一次只能让一辆车进入停车场。
  • 使用信号量:一次可以让四辆车进入停车场。

22.2 步骤

  1. 创建信号量
1
sem_t sem;
  1. 初始化信号量
1
sem_init(sem_t *sem, int pshared, unsigned int value);
  • pshared:pshared == 0,线程同步;pshared == 1,进程同步。
  • value:最多允许 value 个线程操作数据。
  1. 加锁
1
sem_wait(sem_t *sem);
  • 调用一次 wait,相当于对 sem 做了 -- 操作。

  • 当 sem 减为 0时,线程会阻塞。

  1. 尝试加锁
1
sem_trywait(sem_t *sem);
  • sem == 0,加锁失败,但是不阻塞,直接返回错误号。
  1. 限时尝试加锁

在一定时间内,不断尝试加锁,时间结束,不再尝试。

1
sem_timedwait(sem_t *sem, xxxx);
  1. 解锁
1
sem_post(sem_t *sem);
  • 对 sem 做 ++ 操作。
  1. 销毁信号量/释放资源
1
sem_destroy(sem_t *sem);

22.3 生产者消费者思路

生产者线程

1
2
3
4
5
6
7
8
9
10
sem_init(&produce, 2);     // 给生产者分配 2 个资源 
while (1) {
sem_wait (&produce); // produce--,表示可生产的数量越来越少


/* 代码:生产节点 */
/* 代码:将节点加入缓冲区 */

sem_post(&customer); // 给消费者增加一个产品,customer++
}

消费者线程

1
2
3
4
5
6
7
8
sem_init(&customer, 0);    // 给 消费者 分配 0 个产品
while (1) {
sem_wait(&customer); // 判断是否有产品消费:无产品--阻塞,有产品,customer--

/* 代码:消费节点 */

sem_post(&produce); // 将占有的资源还给生产者,produce++
}

22.4 实例:生产者消费者模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
struct Node {
Node* next;
int data;
Node(Node *ptr, int number) : next(ptr), data(number) {}
};

// 定义缓冲区 buffer
Node* head = NULL;

// 1. 创建信号量
sem_t produce_sem, customer_sem;

void* customer(void *) {
while (1) {

// 3. 加锁
sem_wait(&customer_sem);
Node *tmp = head;
head = head->next;
std::cout << "-----customer: " << tmp->data << std::endl;
delete(tmp);

// 4. 解锁
sem_post(&produce_sem);
tmp = NULL;
}
}

void* produce(void *) {
while (1) {
srand((unsigned int)time(NULL));
Node* tmp = new Node(NULL, rand()%999);

// 3. 加锁
sem_wait(&produce_sem);
tmp->next = head;
head = tmp;
std::cout << "+++++produce: " << head->data << std::endl;

// 4. 解锁
sem_post(&customer_sem);
tmp = NULL;
}
}

int main() {

// 2. 初始化信号量
sem_init(&produce_sem, 0, 4);
sem_init(&customer_sem, 0, 0);

pthread_t pth1, pth2;
pthread_create(&pth1, NULL, customer, NULL);
pthread_create(&pth2, NULL, produce, NULL);

pthread_join(pth1, NULL);
pthread_join(pth2, NULL);

// 5. 释放资源
sem_destroy(&produce_sem);
sem_destroy(&customer_sem);

return 0;
}

22.5有个问题

用信号量来实现消费者生产者模型时,

customer_sem 设为 0、produce_sem 设为 2,

简化来看,缓冲区是全局变量 num = 0,消费者做 – 操作,生产者做 ++ 操作。

当生产者有一个产品时,num = 1,customer_sem = 1,produce_sem = 1。

此时,消费者可以做 – 操作,生产者也可以做 ++ 操作,

请问如何保持对竞争资源 num 的互斥?是 信号量内部自己实现的互斥吗?


本博客所有文章均个人原创,除特别声明外均采用 CC BY-SA 4.0协议,转载请注明出处!