Linux基础
作为任何一个计算机行业相关人事都应该了解学习的操作系统
Linux
介绍
发行版(ubuntu)
- 主版本号为当年年份,长期支持版的年份是偶数,测试版本的年份是奇数
- 副版本号为月份,在4月份发布的为相对稳定版本,在10月份发布的为测试版本
内核
Linux系统从应用角度来看,分为内核空间和用户空间
内核主要由五个子系统组成
进程调度 SCHED
进程调度指的是系统对进程的多种状态之间转换的策略
- SCHED_OTHER:分时调度策略(默认),用于针对普通进程的时间片轮转调度策略
- SCHED_FIFO:实时调度策略,针对运行的实时性要求比较高、运行时间比较短的进程调度策略
- SCHED_RR:实时调度策略,针对运行的实时性要求比较高、运行时间比较长的进程调度策略
内存管理 MMU
- 内存管理(虚拟内存)是多个进程之间内存共享策略
- 虚拟内存可以让进程拥有比实际物理内存更大的内存
- 每个进程的虚拟地址有不同的地址空间,多个进程的虚拟内存不会冲突
虚拟文件系统 VFS
Linux下支持多种文件系统:ext2 ext3 vfat ntfs proc smb ncp sysv
- Linux下最常用的文件格式为ext2 ext3
网络接口
网络接口分为网络协议和驱动程序
进程间通信
Linux系统支持多进程,进程之间需要进行数据的交流才能完成控制,协同工作等功能
Linux的进程间通信是从UNIX系统继承过来的
- 管道
- 信号
- 消息队列
- 共享内存
- 套接字
Linux目录
Linux采用将整个文件系统表示成树状的结构,采用挂载的方式将所有分区放在“根”下的各个目录中
目录结构(ubuntu)
bin:二进制文件目录,存储了可执行程序,执行的命令对应的可执行程序都在这个目录sbin:root用户使用的一些二进制可执行程序etc:配置文件目录lib:存储一些动态库和静态库,给系统和安装的软件使用media:挂载目录,挂载外部设备,像光驱扫描仪等mnt:临时挂载目录,可以将U盘挂载在这proc:内存使用的一个映射目录,供操作系统使用tmp:临时文件目录,存放临时数据,重启电脑后自动删除boot:开机相关设置home:普通用户家目录root:root用户家目录dev:设备目录,Linux下一切皆文件,所有硬件会抽象成文件存储起来lost+found:电脑异常关闭或崩溃时用来存储无家可归的文件,用于用户系统恢复opt:第三方软件的安装目录var:存储系统上一些经常变化的文件,像日志文件usr:系统的资源目录/usr/bin:可执行的二进制应用程序/usr/games:游戏目录/usr/include:标准头文件目录/usr/local:和opt类似,安装第三方软件
文件管理命令
cd命令
1 | cd <path> |
path可以是相对目录和绝对目录..表示上一级目录,.表示当前目录~表示/home/usernamecd -可快速切换到上次所在的目录
ls命令
1 | ls <args> |
文件类型
-:普通文件d:目录l:软链接(相当于快捷方式)c:字符设备b:块设备p:管道文件s:本地套接字文件
用户类型
文件所有者
Linux中的所有文件都有一个所有者,就是文件的主人
文件所属组
文件的主人属于哪个组,这个文件就默认也就属于哪个组
用户组可以有多个用户,这些组中的其他用户和所有者的权限可以是不一样的
其他人
这个用户既不是文件所有者也不是文件所属组中的用户
其他人对文件也可以拥有某些权限
文件权限
r:读权限w:写权限x:执行权限-:无权限
硬链接数目
硬链接数N>=1,说明在一个或多个目录下公有N个文件,但是这N个文件并不占用多块磁盘空间,使用的是同一块。通过其中一个修改了磁盘数据,其他文件中的内容也就变了
其它属性
- 文件大小(如果是目录,不包括目录中内容的大小),单位是字节
- 文件日期,文件最后修改日期
- 文件名(如果显示形式为
link -> /root/file/test,后面路径表示的是快捷方式链接的是哪个磁盘文件)
创建删除目录
1 | # 创建单层目录 |
1 | rmdir dirname # 只能删除空目录 |
cp命令
拷贝文件(文件不存在得到新文件,文件存在就覆盖)
1
cp pathA/fileA pathB/fileB
拷贝目录(目录不存在得到新目录,该目录被拷贝到存在的目录中)
1
2
3
4# 拷贝目录需要参数 -r
cp -r dirA dirB
# 拷贝pathA中所有内容到pathB
cp -r pathA/* pathB
mv命令
既可以移动文件也可以给文件改名
文件的移动
1
mv pathA/fileA pathB
文件改名
1
mv pathA/fileA pathA/fileB
文件覆盖
1
mv pathA/fileA pathB/fileA
查看文件内容
最常用的查看文件方式是使用vim
cat1
2# 适合查看比较小的文件
cat <filename>more1
2
3
4
5
6more <filename>
# 回车:显示下一行
# 空格:向后翻页
# b:向前翻页
# 上下方向键:前后翻页
# q:退出less1
2
3
4
5
6less <filename>
# b:向前翻页
# 空格:向后翻页
# 回车:显示下一行
# 上下方向键:上下滚动
# q:退出head1
2# 查看文件前n行,默认10行
head -n <filename>tail1
2# 查看文件尾若干行,默认10行
tail -n <filename>
链接的创建
链接分为软链接和硬链接,软链接相当于快捷方式,硬链接只是多出一个新的文件名并且硬链接数加1
软链接
1
ln -s <src> <dest>
硬链接
1
ln <src> <dest>
目录是不允许创建硬链接的
文件属性
文件属性相关的命令主要是修改用户对文件的操作权限,文件所有者,文件所属组的相关信息
修改文件权限
文件权限是针对文件所有者,文件所属组,其他人
文字表示法
1
2
3
4chmod <who> [+ - =] <mod> <filename>
# who u g o a
# [+-=] + - =
# mod r w x -数字表示法
1
2
3chmod [+ - =] <mod> <filename>
# [+-=] + - =
# mod 7rwx 4r 2w 1x 0-
修改文件所有者
1
2sudo chown <new_ower> <filename>
sudo chown <new_ower>:<new_group> <filename>修改文件所有组
1
sudo chgrp <new_group> <filename>
其他命令
tree1
tree <path> [-L n] # 显示目录的层数
pwd1
pwd # 查看当前目录
touch1
2# 创建文件
touch <filename>which查看执行的命令所在的路径
该命令只能查看非内建的shell指令所在的实际路径,有些命令是写在内核中的,无法查看
我们使用的大部分命令都是在
/bin/usr/bin目录下1
which <command>
whatis1
2# 查看一个命令执行什么功能
whatis lswhereis1
2# 查看二进制程序,代码等相关文件路径
whereis <filename>重定向
输入重定向
1
<command> < <data>
输出重定向
1
2
3<command> > <res>
# 将输出内容追加到指定的文件末尾
<command> >> <res>
1
python3 a.py < data.in > res.out
用户管理命令
切换用户
Linux是一个多用户的操作系统,切换用户需要使用
su或者su -
su仅会切换用户,不会切换当前工作目录
su -不仅会切换用户,还会切换当前工作目录到当前用户的家目录如果想切换回去,可以直接使用
exit
1 | su <user_name> |
添加删除用户
需要root才能给系统添加新用户,或者给普通用户添加管理员权限
添加新用户使用
adduser/useradd
添加用户
1
2
3
4sudo useradd -m -s /bin/bash <user_name>
sudo passwd <user_name> # 设置密码
# -m 用户家目录不存在则创建
# -s 指定shell删除用户
1
2
3sudo userdel -r -f <user_name>
# -r 一并删除用户的家目录
# -f 强制删除
添加删除用户组
默认情况下,创建新用户就会得到一个同名的用户组,并且这个用户属于这个组。如果需要可以使用
groupadd添加用户组,使用groupdel删除用户组
添加用户组
1
2
3
4
5
6
7
8
9sudo groupadd <group_name>
# 在创建用户时加入用户组
useradd -G <group1> <group2> <user_name>
# 设置新的主组
useradd -g <main_group> -G <sub_group> <user_name>
# 将已有的用户添加至用户组
usermod -a -G <group_name> <user_name>删除用户组
1
sudo groupdel <group_name>
修改密码
1 | # 修改当前用户 |
添加sudo权限
sudoers位于/etc下,默认没有写权限
1
2
3
4 su root
chmod +200 sudoers
# 加上
username ALL=(ALL:ALL) ALL
压缩命令
Linux上常用压缩格式:
tar.gztgztar.bz2ziprartar.xz
tar
Linux上系统自带的两个原始压缩工具:
gzipbzip2,但是不能打包压缩文件,压缩之后不会保留源文件Linux上自带的打包工具,
tar不能进行压缩操作,tar和gzipbzip2结合,最强大的打包压缩工具
压缩
一般认为
tgz文件等同于tar.gz,他们的压缩方式是相同的c:创建压缩文件z:使用gzip的方式进行文件压缩j:使用bzip2的方式进行文件压缩v:压缩过程中显示压缩信息,可以省略不写f:指定压缩包的名字
1
2
3
4# 压缩目录中所有文件(使用gzip)
tar czvf all.tar.gz *
# 压缩指定文件(使用bzip2)
tar cjvf all.tar.bz2 <f1 f2 f3...>解压缩
x:释放压缩文件内容z:使用gzip的方式进行解压缩j:使用bzip2的方式进行解压缩v:解压缩过程中显示解压缩信息f:指定压缩包的名字
如果需要解压到指定目录,需要指定参数
-C1
2
3
4# 将all.tar.gz解压到<path>目录(gzip)
tar xzvf all.tar.gz -C <path>
# 将all.tar.bz2解压到当前目录(bzip2)
tar xjvf all.tar.bz2
zip
压缩
zip使用
zip压缩目录需要注意,必须添加-r参数才能将子目录中的文件一并压缩1
2# 将当前目录下所有内容压缩
zip -r all *解压缩
unzip使用
uzip解压缩到指定目录,需要给定-d参数,默认当前目录1
unzip all.zip -d <path>
rar
压缩
1
rar a -r all *
解压缩
1
rar x all.rar <path>
xz
tar.xz格式的文件压缩和解压缩都比较麻烦,通过一个命令是完不成对应的操作的。使用
tar工具打包,xz工具压缩
压缩
1
2tar cvf all.tar *
xz -z all.tar解压缩
1
2xz -d all.tar.xz
tar xvf all.tar
查找命令
find
根据文件属性查找
文件名
根据文件名搜索有两种方式:精确查询和模糊查询
模糊查询必须要使用对应的通配符
*匹配零个或多个字符?匹配单个字符如果我们进行模糊查询,建议将带有通配符的文件名写到引号中
1 | find <path> -name <filename> |
文件类型
| 文件类型 | 类型的字符描述 |
|---|---|
| 普通文件类型化 | f |
| 目录类型 | d |
| 软链接类型 | l |
| 字符设备类型 | c |
| 块设备类型 | b |
| 管道类型 | p |
| 本地套接字类型 | s |
1 | find <path> -type <filetype> |
文件大小
文件大小的单位
KMG在进行文件大小判断的时候,需要指定相应的范围
+-
1 | find . -size +3M # file > 3M |
-size 4K(4-1K, 4K]
-size -4K[0K, 4-1K]
-size +4K(4K, +inf)
目录层级
Linux的目录是树状结构,可以指定搜索层级
-maxdepth-mindepth
maxdepth:最多搜索到多少层目录mindepth:至少从多少层开始搜索
1 | find . -maxdepth 5 -name "*.txt" # 最多搜索五层 |
同时执行多个操作
在搜索文件的时候如果想在一个
find执行多个操作,通过使用管道|的方式是行不通的如果想实现上面的需求,需要在
find中使用execokxargs
exec添加
exec后需要在命令的尾部加上{} \;1
find <path> <arg> <argv> -exec <command> {} \;
1
find . -maxdepth 3 -name "*.txt" -exec ls -l {} \;
ok使用方式和
exec类似,但是这个参数是交互式的1
find <path> <arg> <argv> -ok <command> {} \;
xargs在使用
exec和ok时需要在尾部加上{} \;,使用xargs就可以结合管道来完成1
find <path> <arg> <argv> | xargs <command>
1
find . -name "*.txt" | xargs ls -l
grep
-r:搜索目录中的文件内容,需要递归操作,指定该参数-i:搜索关键字,忽略字符大小写的差别-n:在显示符合样式那一行之前,表示出该行的列数编号
1 | grep <"info"> <path>/<file> <arg> |
1 | grep "include" a.c |
locate
locate可以看做是一个简化版的find,但是他并不在具体的目录中搜索,而是在一个数据库文件中搜索
1 | # 使用前更新数据库 |
搜索以某个关键字开头的文件
1
2
3locate test
# 指定目录 /home/user/ 下(必须是绝对路径)
locate /home/user/test搜索时忽略大小写
-i1
locate Test -i
列出前N个匹配到的文件名称或路径名称
-n1
locate test -n 5
基于正则表达式
-r1
2# 搜索以.cpp结尾的文件
locate -r "\.cpp$"
文件描述符
虚拟地址空间
虚拟地址空间是一个非常抽象的概念
- 它可以用来加载程序数据(数据可能被加载到物理内存上,空间不够就加载到虚拟内存上)
- 它对应一段连续的内存地址,起始位置为0
- 之所以说虚拟是因为这个起始的0地址是被虚拟出来的,不是物理内存的0地址
虚拟地址空间大小由操作系统决定,32位操作系统虚拟地址空间大小为2^32字节,4G
当我们运行磁盘上的一个可执行程序时,就会得到一个进程,内核会给每个运行的进程创建一块属于
自己的虚拟地址空间,并将应用程序数据装载到虚拟地址空间对应的地址上
进程在运行过程中,程序内部所有指令都是通过CPU处理完成的,CPU只进行数据运算并不具备数据
存储的能力,其数据处理都是通过加载物理内存,进程中的数据通过CPU中的内存管理单元MMU从
进程的虚拟地址空间映射过去的
存在的意义
直接在物理内存上分配内存会出现以下问题:
每个进程的地址不隔离,有安全风险
由于程序都是直接访问物理内存,恶意程序可以通过内存寻址随意修改别的进程对应的内存
数据
内存效率低
如果直接使用物理内存,一个进程对应的内存块就是作为一个整体操作的,如果内存不够的
话,一般是将不常用的进程拷贝到磁盘的交换分区(虚拟地址)中,腾出内存,需要将整个
进程拷贝走,效率低下
进程中数据的地址不确定,每次都会发生变化
物理内存使用情况一直在动态变化,无法确定内存现在使用到哪里,如果直接将程序加载到
物理内存,内存中每次存储数据的起始地址1都是不一样的,这样数据的加载要使用相对地
址,加载效率低
虚拟地址空间就是一个中间层,相当于在程序和物理内存之间设置了一个屏障,将二者隔离开来。程序中
访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到
适当的物理内存地址上只要操作系统处理好虚拟地址到物理地址的映射,就可以保证不同的程序最终
访问的内存地址位于不同的区域,彼此没有重叠,可以达到内存地址空间隔离的效果
分区
从操作系统层级上看,虚拟地址空间主要分为内核区和用户区
- 内核区
- 内核空间为内核保留,不允许应用程序读写该区域的内容或直接调用内核代码定义的函数
- 内核总是驻留在内存中,是操作系统的一部分
- 系统中所有进程对应的虚拟地址空间的内核区都会映射到同一块物理内存上(系统内核只有一个)
- 用户区:存储用户程序运行中用到的各种数据
每个进程的虚拟地址都是从0开始的,我们在程序中打印的变量地址也在其虚拟地址空间中的地址,程序是
无法直接访问物理内存的。虚拟地址空间中用户区范围是0~3G(32位系统为例),里面分为多个区块:
保留区:位于虚拟地址空间的最底部,未赋予物理地址。任何对它的引用都是非法的,程序中的空指针
就是指向的这块内存地址.text段:代码段也称正文段或文本段,通常用于存放程序的执行代码(即CPU执行的机器指令),代
码段一般情况下是只读的,这是对执行代码的一种保护机制.data段:数据段通常用于存放程序中已初始化且初值不为0的全局变量和静态变量。数据段属于静态内
存分配(静态存储区),可读可写.bss段:未初始化以及初始为0的全局变量和静态变量,操作系统会将这些未初始化变量初始化为0堆 heap:用于存放进程运行时动态分配的内存
- 堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问
- 堆向高地址扩展,是不连续的内存区域。系统用链表来存储空闲内存地址,自然不连续,而链表从
低地址向高地址遍历内存映射区 mmap:作为内存映射区加载磁盘文件,或者加载程序运行过程中需要调用的动态库栈 stack:存储函数内部声明的非静态局部变量,函数参数,函数返回地址等,栈内存由编译器自动
分配释放。栈向低地址扩展,分配的内存是连续的命令行参数:存储进程执行的时候传递给main()函数的参数环境变量:存储和进程相关的环境变量,工作路径,进程所有者等信息
文件描述符
文件描述符
在Linux操作系统中的一切都被抽象成了文件,使用文件描述符
file descriptor(fd),当进程中打开一个现有文件或者创建一个新文件时,内核向该进程返回一个文件描述符,用于对应这个打开、新建的文件。这些文件描述符都存储在内
核为每个进程维护的一个文件描述符表中在Linux系统中一切皆文件,系统中的一切都被抽象成了文件,对这些文件的读写都需要通过文件描述符来完成
文件描述符表
启动一个进程就会得到一个对应的虚拟地址表,这个虚拟地址空间分为两大部分,在内核区有专门用于进程管理的模块
Linux的进程控制块PCB本质是一个叫做
task_struct的结构体,里面包括管理进程所需的各种信息,其中一个结构体
叫做file,我们将它叫做文件描述表,里面有一个整形索引表,用于存储文件描述符内核会为每一个进程维护一个文件描述表,索引表中的值都是从0开始的,所以在不同的进程会看到相同的文件描述
符,但是它们指向的不一定是同一个磁盘文件
打开的最大文件数
每一个进程对应的文件描述符能够存储的打开的文件数是有限制的,默认1024个,可以修改,上限取决于硬件
默认分配的文件描述符
当一个进程被启动之后,内核PCB的文件描述符表中就已经分配了三个文件描述符,这三个文件描述符对应的都
是当前启动这个进程的终端文件,在/dev中STDIN_FILENO:标准输入,通过该文件描述符将数据输入到终端文件中,宏值为0STDOUT_FILENO:标准输出,通过该文件描述符将数据输出到终端文件中,宏值为1STDERR_FILENO:标准错误,通过该文件描述符将错误信息通过终端输出出来,宏值为2
这三个默认分配的文件描述符可以通过
close()关掉,单数关闭之后当前进程就不能和当前终端进行输入
或者输出的信息交互了给新打开的文件分配文件描述符
进程启动后,文件描述符表中的
0 1 2就被分配出去了,因此从3开始分配在进程中每打开一个文件,就会给这个文件分配一个新的文件描述符
使用
open()打开一个文件,文件描述符3就被分配给这个文件,再打开一个文件,文件描述符4被分配使用之后将文件关闭,文件描述符就被释放
Linux系统文件IO
系统函数不是内核函数,是系统的专属函数
下面是一些Linux系统IO函数,和标准C库的IO函数使用方法类似
open/close
open函数原型
1 | /* |
参数介绍:
pathanme:被打开的文件的文件名flag:使用什么方式打开文件,这个参数对应一些宏值O_RDONLY:只读O_WRONLY:只写O_RDWR:读写可选属性
O_APPEND:新数据追加到文件尾部,不会覆盖文件的原来内容O_CREAT:如果文件不存在就创建文件O_EXCL:检测文件是否存在,必须和O_CREAT一起使用:O_CREAT | O_EXCL检测到文件不存在就创建文件
检测到文件存在就返回-1
mode:在创建新文件时才需要指定该参数,用于指定新文件的权限这个参数最大值为:
0777创建的新文件对应的最终实际权限:
mode & ~umask1
umask # 查看umask
返回值:
- 成功:返回内核分配的文件描述符,这个值被记录在内核的文件描述符表中
- 失败:-1
close函数原型
1 | int close(int fd); |
- 函数参数:
fd是文件描述符,是open()函数的返回值 - 函数返回值:函数调用成功返回值0,调用失败返回-1
打开已存在文件
我们可以使用
open打开一个本地已经存在的文件
1
2 int fd = open("file", O_RDWR);
close(fd);
创建新文件
如果要创建一个新的文件,还是使用
open函数,需要添加O_CREAT属性,并且给新文件指定操作权限
1
2 int fd = open("file", O_CREAT | O_RDWR, 0664);
close(fd);
文件状态判断
在创建新文件的时候我们还可以通过
O_EXCL进行文件的检测
1
2
3
4 // 创建新文件之前, 先检测是否存在
// 文件存在创建失败, 返回-1, 文件不存在创建成功, 返回分配的文件描述符
int fd = open("file", O_CREAT | O_EXCL | O_RDWR);
close(fd);
read/write
read
read函数用于读取文件内部数据,在通过open打开文件的时候需要指定读权限
1 ssize_t read(int fd, void *buf, size_t count);
- 参数
fd:文件描述符,open函数的返回值,通过这个参数定位打开的磁盘文件buf:是一个传出参数,指向一块有效的内存,用于存储从文件中读出的数据count:buf指针指向的内存的大小,指定可以存储的最大字节数
- 返回值
- 大于0:从文件中读出的字节数,读文件成功
- 等于0:文件读完了,读文件成功
- -1:读文件失败了
write
write函数用于将数据写入到文件内部,在通过open打开文件的时候需要指定写权限
1 ssize_t write(int fd, const void *buf, size_t count);
- 参数
fd:文件描述符,open函数返回值,通过这个参数定位打开的磁盘文件buf:指向一块有效的内存地址,里面有要写入到磁盘文件中的数据count:要往磁盘文件中写入的字节数,一般情况下就是buf字符串的长度
- 返回值
- 大于0:成功写入到磁盘文件中的字节数
- -1:写文件失败了
文件拷贝
假设有一个比较大的磁盘文件,打开这个文件得到文件描述符 fd1,然后在创建一个新的磁盘文件得到文件描述符 fd2
在程序中通过 fd1 将文件内容读出,并通过 fd2 将读出的数据写入到新文件中
lseek
可以通过
lseek函数来移动文件指针,也可以通过这个函数进行文件的扩展
1 off_t lseek(int fd, off_t offset, int whence);
- 参数
fd:文件描述符,open函数的返回值,通过这个参数定位打开的磁盘文件offset:偏移量,需要和第三个参数配合使用whence:通过这个参数指定函数实现什么样的功能SEEK_SET:从文件头部开始偏移offset个字节SEEK_CUR:从当前文件指针的位置向后偏移offset个字节SEEK_END:从文件尾部向后偏移offset个字节
- 返回值
- 成功:文件指针从头部开始计算总的偏移量
- 失败:-1
移动文件指针
通过lseek函数第三个参数的设置,经常使用该函数实现如下几个功能
文件指针移动到文件头部
1
lseek(fd, 0, SEEK_SET);
得到当前文件指针的位置
1
lseek(fd, 0, SEEK_CUR);
得到文件总大小
1
lseek(fd, 0, SEEK_END);
文件扩展
下载大文件时,先进行文件扩展,将一些字符写入到目标文件中,让拓展的文件和即将被下载的文件一样大,这样
磁盘就成功抢到手使用
lseek函数进行文件扩展必须要满足下面两个条件:
- 文件指针必须要偏移到文件尾部之后,多出来的就需要被填充
- 文件扩展之后,必须使用
write函数进行一次写操作(写什么都可以,没有字节数要求)
truncate/ftruncate
truncate/ftruncate这两个函数功能是一样的,可以对文件进行扩展也可以截断文件。使用这两个扩展文件
比使用lseek要简单
1 | int truncate(const char *path, off_t length); |
- 参数
path:要扩展、截断的文件的文件名fd:文件描述符,open函数得到length:文件的最终大小- 文件原来
size > length,文件被截断,尾部多余的部分被删除,文件最终长度为length - 文件原来
size < length,文件被拓展,文件最终长度为length
- 文件原来
- 返回值:成功返回0,失败返回-1
truncate() ftruncate()两个函数最大区别在于一个使用文件名,一个使用文件描述符
perror
在Linux大多数系统函数中都是通过返回值来描述系统函数的状态
errno是一个全局变量,只要调用的Linux系统函数有异常(返回-1),错误对应的错误号就会被设置给这个全局变量,这个错误号存储在系统的两个头文件中:
/usr/include/asm-generic/errno-base.h
/usr/include/asm-generic/errno.h得到错误号,去查询对应的头文件是非常不方便的,我们可以通过
perror函数将错误号对应的信息输出
1
2
3
// 参数, 自己指定这个字符串的值就可以, 指定什么就会原样输出, 除此之外还会输出错误号对应的描述信息
void perror(const char *s);
错误号
/usr/include/asm-generic/errno-base.h
/usr/include/asm-generic/errno.h
文件属性信息
使用
命令或函数查看某一个文件的属性
file命令
该命令用来识别文件类型,也可用来辨别一些文件的编码格式,通过查看文件头部信息来获取文件类型
1 | file <filename> [args] |
file命令的参数是可选项
| 参数 | 功能 |
|---|---|
-b |
只显示文件类型和文件编码,不显示文件名 |
-i |
显示文件的MIME类型 |
-F |
设置输出字符串的分隔符 |
-L |
查看软链接文件自身文件属性 |
查看文件类型和编码格式
1
file <filename>
只显示文件格式以及编码
1
file <filename> -b
显示文件的MIME类型
1
file <filename> -i
MIME(多用途互联网邮件扩展类型),是设定某种扩展名的文件用一种应用程序来打开的方式类型设置输出分隔符
1
file <filename> -F "flag"
默认使用
:分隔,可以通过-F参数修改分隔符查看软链接文件
1
file <filename> -L
stat命令
stat命令显示文件或目录的详细属性信息包括文件系统状态,比ls命令输出的信息更详细
1 stat [args] <filename>
stat命令的可选参数:
参数 功能 -f不显示文件本身的信息,显示文件所在文件系统的信息 -L查看软链接文件关联的文件的属性信息 -c查看文件某个单个的属性信息 -t简洁模式,只显示摘要信息,不显示属性描述
显示所有属性
1
stat <filename>
在输出中我们可以看到很多属性:
File:文件名Size:文件大小,单位是字节Blocks:文件使用的数据块总数IO Block:IO块大小regular file:文件的实际类型,文件类型不同,该关键字也会变化Device:设备编号Inode:Inode号,操作系统用inode编号来识别不同的文件,找到文件数据所在的block,读出数据Links:硬链接计数Access:文件所有者+所属组用户+其他人对文件的访问权限Uid:文件所有者名字和所有者IDGid:文件所有组名字和组IDAccess Time:文件访问时间,当文件被访问时这个时间更新Modify Time:文件内容修改时间,当文件内容数据被修改时这个时间更新Change Time:文件状态时间,当文件状态被修改时,这个时间更新Birth:文件生成的日期
只显示系统信息
1
stat <filename> -f
软链接文件
1
stat <filename> -L
简洁输出
1
stat <filename> -t
单个属性输出
1
stat <filename> -c %fmt
格式化字符 功能 %a文件的八进制访问权限(#和 0 是输出标准) %A人类可读形式的文件访问权限(rwx) %b已分配的块数量 %B报告的每个块的大小 (以字节为单位) %CSELinux 安全上下文字符串 %d设备编号 (十进制) %D设备编号 (十六进制) %F文件类型 %g文件所属组组 ID %G文件所属组名字 %h用连接计数 %iinode 编号 %m挂载点 %n文件名 %N用引号括起来的文件名,并且会显示软连接文件引用的文件路径 %o最佳 I/O 传输大小提示 %s文件总大小,单位为字节 %t十六进制的主要设备类型,用于字符 / 块设备特殊文件 %T十六进制的次要设备类型,用于字符 / 块设备特殊文件 %u文件所有者 ID %U文件所有者名字 %w文件生成的日期 ,人类可识别的时间字符串 – 获取不到信息不显示 %W文件生成的日期 ,自纪元以来的秒数 (参考 % X )– 获取不到信息不显示 %x最后访问文件的时间,人类可识别的时间字符串 %X最后访问文件的时间,自纪元以来的秒数(从 1970.1.1 开始到最后一次文件访问的总秒数) %y最后修改文件内容的时间,人类可识别的时间字符串 %Y最后修改文件内容的时间,自纪元以来的秒数(参考 % X ) %z最后修改文件状态的时间,人类可识别的时间字符串 %Z最后修改文件状态的时间,自纪元以来的秒数(参考 % X )
stat/lstat函数
stat/lstat函数的功能和stat命令的功能是一样的,只不过应用场景不同
lstat():得到的是软链接文件本身的属性信息stat():得到的是软链接文件关联的文件的属性信息
1 | int stat(const char *pathname, struct stat *buf); |
- 参数
pathname:文件名,要获取这个文件的属性信息buf:传出参数,文件的信息被写入到了这块内存中
- 返回值:函数调用成功返回0,调用失败返回-1
获取文件大小
1 |
|
获取文件类型
文件的类型信息存储在
struct stat结构体中的st_mode成员中,它是一个mode_t类型(16位的整数)
1 | S_ISREG(m) is it a regular file? |
获取文件权限
用户对文件的操作权限也存储在
struct stat结构体st_mode成员中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 ○ 0-2 bit -- 其他人权限
- S_IROTH 00004 读权限 100
- S_IWOTH 00002 写权限 010
- S_IXOTH 00001 执行权限 001
- S_IRWXO 00007 掩码, 过滤 st_mode中除其他人权限以外的信息
○ 3-5 bit -- 所属组权限
- S_IRGRP 00040 读权限
- S_IWGRP 00020 写权限
- S_IXGRP 00010 执行权限
- S_IRWXG 00070 掩码, 过滤 st_mode中除所属组权限以外的信息
○ 6-8 bit -- 文件所有者权限
- S_IRUSR 00400 读权限
- S_IWUSR 00200 写权限
- S_IXUSR 00100 执行权限
- S_IRWXU 00700 掩码, 过滤 st_mode中除文件所有者权限以外的信息
○ 12-15 bit -- 文件类型
- S_IFSOCK 0140000 套接字
- S_IFLNK 0120000 符号链接(软链接)
- S_IFREG 0100000 普通文件
- S_IFBLK 0060000 块设备
- S_IFDIR 0040000 目录
- S_IFCHR 0020000 字符设备
- S_IFIFO 0010000 管道
- S_IFMT 0170000 掩码,过滤 st_mode中除文件类型以外的信息
文件描述符复制和重定向
在Linux中只要调用
open函数就可以给操作的文件分配一个文件描述符,除了使用这种方式Linux系统还
提供了一些其他的API用于文件描述符的分配,相关函数有三个:dupdup2fcntl
dup
dup函数的作用是复制文件描述符,这样就有多个文件描述符可以指向同一个文件
1 int dup(int oldfd);
- 参数:
oldfd是要被复制的文件描述符- 返回值:函数调用成功返回被复制出来的文件描述符,调用失败返回-1
dup2
dup2是对dup的一个加强版,基于dup2既可以进行文件描述符的复制,也可以进行文件描述符的重定向。
文件描述符重定向就是改变已经分配的文件描述符关联的磁盘文件
1 int dup2(int oldfd, int newfd);
- 参数:
oldfd和newfd都是文件描述符- 返回值:函数调用成功返回新的文件描述符,调用失败返回-1
两个使用场景:
场景一
假设参数
oldfd对应磁盘文件a.txx,newfd对应磁盘文件b.txt。在这种情况下调用dup2函数,是给newfd做了重定向,newfd和文件b.txt断开关联,相当于关闭了这个文件,同时newfd指向了磁盘上的a.txt文件,最终oldfd newfd都指向了磁盘文件a.txt场景二
假设参数oldfd对应磁盘文件a.txt,newfd不对应任何的磁盘文件(newfd必须是一个大于等于0的整数)
此时调用dup2函数,这种情况下会进行文件描述符的复制,newfd指向了磁盘上的a.txt文件,最终oldfdnewfd都指向了磁盘文件a.txt场景三
假设参数oldfd newfd两个文件描述符对应的是同一个磁盘文件a.txt,在这种情况下调用dup2函数,相当
于啥也没发生,不会有任何改变
fcntl
fcntl是一个变参函数,并且是多功能函数,这里只介绍如何通过这个函数实现文件描述符的复制和获取、设置已
打开的文件属性
1 int fcntl(int fd, int cmd, ... /* arg */ );
- 参数
fd:要操作的文件描述符cmd:通过该参数控制函数要实现什么功能- 返回值:函数调用失败返回-1,调用成功返回正确的值
- 参数
cmd = F_DUPFD:返回新的被分配的文件描述符- 参数
cmd = F_GETFL:返回文件的flag属性
fcntl函数的cmd可使用的参数列表:
参数cmd的取值 功能描述 F_DUPFD复制一个已经存在的文件描述符 F_GETFL获取文件的状态标志 F_SETFL设置文件的状态标志 文件的状态标志指的是在使用
open函数打开文件的时候指定的flag属性
1 int open(const char *pathname, int flags);
文件状态标志 说明 O_RDONLY只读打开 O_WRONLY只写打开 O_RDWR读写打开 O_APPEND追加写 O_NONBLOCK非阻塞模式 O_SYNC等待写完成(数据和属性) O_ASYNC异步IO O_RSTNC同步读写
复制文件描述符
使用
fcntl函数进行文件描述符复制,第二个参数cmd需要指定为F_DUPFD(这是个变参函数)1
int newfd = fcntl(fd, F_DUPFD);
设置文件状态标志
通过
open函数打开文件之后,文件的flag属性就已经被确定下来了,如果想要在打开状态下修改这些属性
可以使用fcntl函数实现,但是不是所有的flag属性都能被动态修改,只能修改如下状态标志:O_APPENDO_NONBLOCKO_SYNCO_ASYNCO_BSYNC得到已打开的文件的状态标志,需要将cmd设置为
F_GETFL1
int flag = fcntl(fd, F_GETFL);
设置已打开的文件的状态标志,需要将cmd设置为
F_SETFL,新的flag需要通过第三个参数传递给fcntl函数1
2
3
4
5
6// 得到文件的flag属性
int flag = fcntl(fd, F_GETFL);
// 添加新的flag 标志
flag = flag | O_APPEND;
// 将更新后的falg设置给文件
fcntl(fd, F_SETFL, flag);
目录遍历
Linux的目录是一个树状结构,遍历一棵树最简单的方式就是递归
Linux给我们提供了相关的目录遍历的函数,分别是opendir()readdir()closedir()
目录三剑客
opendir
在目录操作之前必须通过
opendir函数打开这个目录
1
2
DIR *opendir(const char *name);
- 参数:
name要打开的目录的名字- 返回值:
DIR*结构体类型指针,打开成功返回目录的实例,打开失败返回NULL
readdir
目录打开后,可以通过
readdir函数遍历目录中的文件信息
1
2
struct dirent *readdir(DIR *dirp);
- 参数:
dirp -> opendir函数的返回值- 返回值:函数调用成功返回读到的文件信息,目录结构被读完或者函数调用失败返回NULL
函数返回值
struct dirent结构体原型:
1
2
3
4
5
6
7 struct dirent {
ino_t d_ino; /* 文件对应的inode编号, 定位文件存储在磁盘的那个数据块上 */
off_t d_off; /* 文件在当前目录中的偏移量 */
unsigned short d_reclen; /* 文件名字的实际长度 */
unsigned char d_type; /* 文件的类型, linux中有7中文件类型 */
char d_name[256]; /* 文件的名字 */
};关于结构体中的文件类型
d_type,可使用的宏值如下:
DT_BLK:块设备文件DT_CHR:字符设备文件DT_DIR:目录文件DT_FIFO:管道文件DT_LNK:软链接文件DT_REG:普通文件DT_SOCK:本地套接字文件DT_UNKNOWN:无法识别的文件类型
1
2
3
4
5
6
7 // 打开目录
DIR* dir = opendir("/home/test");
struct dirent* ptr = NULL;
// 遍历目录
while( (ptr=readdir(dir)) != NULL) {
.......
}
closedir
目录操作完毕之后,需要通过
closedir关闭通过opendir得到的实例,释放资源
1
2 // 关闭目录, 参数是 opendir() 的返回值
int closedir(DIR *dirp);
- 参数:
dirp -> opendir函数的返回值- 返回值:目录关闭成功返回0,失败返回-1
遍历目录
遍历单层目录
如果只遍历单层目录是不需要递归的
遍历多层目录
Linux的目录是树状结构,遍历每层目录的方式都是一样的,也就是说最简单的遍历方式是递归
程序的重点是确定递归结束的条件:遍历的文件如果不是目录类型就结束递归
scandir函数
除了使用上面介绍的目录三剑客遍历目录,也可以使用scandir函数进行目录的遍历(只遍历指定目录,不进
入到子目录中进行递归遍历),它的参数并不简单,涉及到三级指针和回调函数的使用
1 | // 头文件 |
参数
dirp:需要遍历的目录的名字namelist:三级指针,传出参数,需要在指向的地址中存储遍历目录得到的所有文件的信息在函数内部会给出这个指针指向的地址分配内存,要注意在程序中释放内存filter:函数指针,指针指向的函数就是回调函数,需要在自定义函数中指定- 如果不对目录中的文件进行过滤,该函数的指针指定为NULL即可
- 如果自己指定过滤函数,满足条件要返回1,否则返回0
compar:函数指针,对过滤得到的文件进行排序,可以使用提供的两种排序方式alphasort:根据文件名进行排序versionsort:根据版本进行排序
返回值:函数执行成功返回找到的匹配成功的文件的个数,如果失败返回-1
文件过滤
scandir()可以让使用者们自定义文件的过滤方式,然后将过滤函数的地址传递给scandir的第三个参数
1 | // 函数的参数就是遍历的目录中的子文件对应的结构体 |
基于这个函数指针定义的函数就可以称之为回调函数,这个函数不是由程序员调用,而是通过
scandir调用,因此
这个函数的实参也是由scandir函数提供的,作为回调函数的编写人员,只需要明白这个参数的含义是什么判断目录中某一个文件是否为Mp3格式
1
2
3
4
5
6
7
8
9 int isMp3(const struct dirent *ptr)
{
if(ptr->d_type == DT_REG) {
char* p = strstr(ptr->d_name, ".mp3");
if(p != NULL && *(p+4) == '\0')
return 1;
}
return 0;
}
遍历目录
了解了
scandir()函数的使用之后,下面这个程序是搜索指定目录下mp3格式文件个数和文件名
1 |
|
scandir()的第二个参数,传递的是一个二级指针的地址
1 | struct dirent **namelist = NULL; |
这个struct dirent **namelist指向的是一个指针数组struct dirent *namelist[]
数组元素的个数就是遍历的目录中的个数文件
数组的每个元素都是指针类型:
struct dirent *,指针指向的地址是有scandir()函数分配使用完毕之后释放内存
进程控制
进程概述
从严格意义上讲,程序和进程是两个不同的概念,他们的状态,占用的系统资源都是不同的
- 程序:就是磁盘上的可执行文件,并且只占用磁盘上的空间,是一个静态的概念
- 进程:被执行之后的程序叫做进程,不占用磁盘空间,需要消耗系统的
内存 CPU 资源,每个运行的
进程的都对应一个属于自己的虚拟地址空间,这是一个动态的概念
并行和并发
CPU时间片
CPU在某个时间点只能处理一个任务,但是操作系统都支持多任务的,CPU会给每个进程分配一个时间段,进程
得到这个时间片之后才可以运行,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,CPU
的使用权将被回收,该进程将会被中断挂起等待下一个时间片。如果进程在时间片结束前阻塞或结束,则CPU当即
进行切换,这样就可以避免CPU资源的浪费因此,计算机中启动的多个程序,从宏观上看是同时运行的,从微观上看由于CPU一次只能处理一个进程,所以
它们是轮流执行的,只不过切换速度太快,我们感觉不到罢了,因此CPU的核数越多计算机的处理效率越高并行和并发
这两个概念都可以笼统的解释为:多个进程同时进行,但是它们两个的同时并不是同一个概念
并发
- 并发的同时运行是一个假象,CPU在某一个时间点只能为某一个个体来服务,因此不可能同时处理多任务,只
是通过计算机的CPU快速的时间片切换实现的 - 并发是针对某一个硬件资源而言的,在某个时间段之内处理的任务的总量,量越大效率越高
并行
- 并行的多进程同时运行是真实存在的,可以在同一时刻同时运行多个进程
- 并行需要依赖多个硬件资源,单个是无法实现的
- 并发的同时运行是一个假象,CPU在某一个时间点只能为某一个个体来服务,因此不可能同时处理多任务,只
PCB
PCB进程控制块,Linux内核的进程控制块本质上是一个叫做stack_struct的结构体,这个结构体中记录了进程运行相关的一些信息
进程id:每一个进程都有一个唯一的进程ID,类型为
pid_t,本质是一个整形数进程的状态:进程有不同的状态,状态是一直在变化的,有就绪、运行、挂起、停止等状态
进程对应的虚拟地址空间的信息
描述控制终端的信息,进程在哪个终端启动默认就和哪个终端绑定
当前工作目录:默认情况下,启动进程的目录就是当前的工作目录
umask掩码:在创建新文件时,通过这个掩码屏蔽某些用于文件的操作权限文件描述符表:每个被分配的文件描述符都对应一个已经打开的磁盘文件
和信号相关的信息:在Linux中调用函数、键盘快捷键、执行shell命令等操作都会产生信号
阻塞信号集:记录当前进程中阻塞哪些已产生的信号,使其不能被处理
未决信号集:记录在当前进程中产生的哪些信号还没有被处理掉
用户ID和组ID:当前进程属于哪个用户,属于哪个用户组
会话(Session)和进程组:多个进程的集合叫做进程组,多个进程组的集合叫会话
进程可以使用的资源上限:可以使用shell命令
ulimit -a查看详细信息
进程状态
进程一共有五种状态分别为:创建态、就绪态、运行态、阻塞态(挂起态)、退出态(终止态)
其中创建态和退出态持续的时间是非常短的,我们主要是需要将就绪态 运行态 挂起态三者之间的
状态切换搞明白
就绪态:准备就绪,只差CPU资源
- 进程被创建出来,有运行的资格但是还没有运行,需要抢CPU时间片
- 得到CPU时间片,进程开始运行,从就绪状态转换为运行态
- 进程的CPU时间片用完了,再次失去CPU,从运行态转换为就绪态
运行态:获取到CPU资源的进程,进程只有在这种状态下才能运行
- 运行态不会一直持续,进程的CPU时间片用完之后,再次失去CPU,从运行态装换为就绪态
- 只要进程还没退出,就会在就绪态和运行态之间不停的切换
阻塞态:进程被强制放弃CPU,并且没有抢夺CPU时间片的资格
- 在程序中调用了某些函数(sleep),进程又运行态转换为阻塞态(挂起来)
- 当某些条件被满足(sleep结束),进程的阻塞状态也就被解除了,进程从阻塞态转换为就绪态
退出态:进程被销毁,占用的系统资源被释放了
- 任何状态的进程都可以直接转换为退出态
进程命令
在研究如何创建进程之前,先来看一下如何在终端中通过命令完成进程相关的操作
查看进程
1
2
3
4ps aux
# -a 查看所有终端的信息
# -u 查看用户相关的信息
# -x 显示和终端无关的进程信息杀死进程
kill命令可以发送信号到对应的进程,进程接收到某些信号之后默认的处理动作就是退出进程
如果要给进程发送信号,可以先查看一下Linux给我们提供了哪些标准信号查看Linux中的标准信号
1
kill -l
9号信号(SIGKILL)的行为是无条件杀死进程,想要杀死哪个进程就可以把这个信号发送给这个进程
1
2kill -9 <pid>
kill -SIGKILL <pid>
进程创建
函数
Linux中进程ID为
pid_t类型,其本质是一个正整数,通过上边的ps aux命令已经得到了验证。PID为1
的进程是Linux系统中创建的第一个进程
获取当前进程的进程ID(PID)
1
pid_t getpid(void);
获取当前进程的父进程ID(PPID)
1
pid_t getppid(void);
创建一个新的进程
1
pid_t fork(void);
fork( )
1 | pid_t fork(void); |
启动磁盘上的应用程序,得到一个进程,如果在这个启动的进程中调用fork()函数,就会得到一个新的进程
我们习惯将其称之为子进程。前面说过每个进程都对应一个属于自己的虚拟空间,子进程的地址空间是基于父
进程的地址空间拷贝出来的,虽然是拷贝但是两个地址空间中存储的信息不可能是完全相同的
相同点
拷贝完成之后,两个地址空间中的用户区数据是相同的。用户区数据主要数据包括:
- 代码区:默认情况下父子进程地址空间中的源代码始终相同
- 全局数据区:父进程中的全局变量和变量值全部被拷贝一份放到了子进程地址空间中
- 堆区:父进程中的堆变量和变量值全部被拷贝一份放到了子进程地址空间中
- 动态库加载区(内存映射区):父进程中数据信息被拷贝一份放到了子进程地址空间中
- 栈区:父进程中的栈区变量和变量值全部被拷贝一份放到了子进程地址空间中
- 环境变量:默认情况下,父子进程地址空间中的环境变量始终相同
- 文件描述符表:父进程中被分配的文件描述符都会拷贝到子进程中,在子进程中可以使用它们打开对应的文件
区别
父子进程各自的虚拟地址空间是相互独立的,不会互相干扰和影响
父子进程地址空间中代码区代码虽然相同,但是父子进程执行的代码逻辑可能是不同的
由于父子进程可能执行不同的代码逻辑,因此地址空间拷贝完后之后,
全局数据 栈区 堆区 动态库加载区
数据会各自发生变化,由于地址空间是相互独立的,因此不会互相覆盖数据由于每个进程都有自己的进程ID,因此内核区域存储的父子进程ID是不同的
进程启动之后进入
就绪态,运行需要争抢CPU时间片而且可能执行不同的业务逻辑,所以父子进程的状态
可能是不同的fork()调用成功后,会返回两个值,父子进程的返回值是不同的该函数调用成功后,从一个虚拟地址空间变成了两个虚拟地址空间,每个地址空间中都会将
fork()
的返回值记录下来,这就是为什么会得到两个返回值的原因父进程的虚拟地址空间中将返回值标记为一个大于0的数(记录的是子进程的进程ID)
子进程的虚拟地址空间中将该返回值标记0
在程序中需要通过
fork()的返回值来判断当前进程是子进程还是父进程1
2
3
4
5
6
7
8
9
10
11
12
13int main(void)
{
pid_t pid = fork();
printf("当前进程fork()的返回值: %d\n", pid);
if(pid > 0)
printf("我是父进程, pid = %d\n", getpid());
else if(pid == 0)
printf("我是子进程, pid = %d, 我爹是: %d\n", getpid(), getppid());
else // pid == -1
NULL;
return 0;
}
父子进程
进程执行位置
在父进程中成功创建子进程,子进程就拥有父进程代码区的所有代码,那么子进程中的代码是在什么位置开始运行呢?
父进程肯定从main()开始执行,子进程是在父进程中调用fork()之后被创建,子进程就从fork()之后开始向下执行代码
程序对
fork()的返回值做了判断,就可以控制父子进程的行为,如果没有做任何判断这个代码块父子进程都可以执行
在编写多进程程序时,一定要将代码想象成多份进行分析,因为直观上看代码就一份,但实际上数据都是多份,并且多
份数据中变量名都相同,但是他们的值却不一定相同
循环创建子进程
在一个父进程中循环创建三个子进程,打印每个进程的ID
1 |
|
进程数数
当父进程创建一个子进程,那么父子进程之间可以通过全局变量互动,实现交替数数的功能吗
不可行的,要想实现进程间通信需要使用:
管道 共享内存 本地套接字 内存映射区 消息队列
execl和execlp函数
需要通过现在运行的进程启动磁盘上的另一个可执行程序,也就是通过一个进程启动另一个进程
使用exec族函数
1
2
3
4
5
6
7
8
9
10
11 extern char **environ;
int execl(const char *path, const char *arg, ...
/* (char *) NULL */);
int execlp(const char *file, const char *arg, ...
/* (char *) NULL */);
int execle(const char *path, const char *arg, ...
/*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);这些函数执行成功后不会返回,调用进程的实体,包括
代码段 数据段 堆栈等都已经被新的内容取代,只
留下进程ID等一些表面上的信息仍保持原样,只有调用失败了才会返回一个-1,从原程序的调用点接着往下执行
exec族函数并没有创建新进程的能力,只是让启动的新进程到自己的虚拟地址空间去,并挖空了自己的地址
空间用户区,把新启动的进程数据填充进去
exec族函数中最常用的两个execl() execlp(),这两个函数是对其他四个函数做了进一步的封装
execl( )
该函数可用于执行任意一个可执行程序,函数需要通过指定的文件路径才能找到这个可执行程序
1 | int execl(const char *path, const char *arg, ...); |
- 参数
path:启动的可执行程序的路径,推荐使用绝对路径arg:ps aux查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序相同...:要执行的命令需要的参数,可以写多个,最后以NULL结尾,表示参数完了
- 返回值:如果这个函数执行成功,没有返回值,如果执行失败返回-1
execlp( )
该函数常用于执行已经设置了环境变量的可执行程序,函数中的p就是path,也是说这个函数会自动搜索系统
的环境变量PATH,因此使用这个函数执行可执行程序不需要指定路径,只需要指定出名字即可
1 | int execlp(const char *file, const char *arg, ...); |
- 参数
file:可执行程序的名字- 在环境变量PATH中,可执行程序可以不加路径
- 没有在环境变量中,可执行程序需要指定绝对路径
arg:ps aux查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同...:要执行的命令的参数,可以写多个,最后以NULL结尾,表示参数指定完了
- 返回值:如果函数执行成功,没有返回值,如果执行失败,返回-1
函数的使用
关于exec族函数,我们一般不会在进程中直接调用,如果直接调用,这个进程的代码区代码被替换就不能
按照原来的流程工作了。我们一般在调用这些函数的时候都会创建一个子进程,在子进程中调用exec族函数
子进程的用户区数据被替换掉开始执行新的程序中的代码逻辑,但是父进程不受任何影响仍然可以继续正常工作
进程控制
进程控制主要是指
进程的退出 进程的回收和进程的特殊状态孤儿进程和僵尸进程
结束进程
如果想要直接退出某个进程可以在程序的任何位置调用exit()或_exit()函数。
函数的参数相当于退出码,如果参数值为0程序退出之后的状态码就是0,如果是100退出的状态码就是100
1 | // 标准C库函数 |
在main()中直接使用return也可以退出进程,加入是在一个普通函数中调用return只能返回到调用者的位置
不能退出进程
孤儿进程
在一个启动的进程中创建子进程,如果父进程退出,这时子进程就是孤儿进程
操作系统是十分关爱运行的每一个进程的,当检测到某一个进程变成孤儿进程,这时系统会有一个固定的进程来
领养这个孤儿进程。如果没有桌面终端,这个领养孤儿进程的进程就是init进程(PID=1),如果有桌面终端
这个领养孤儿进程的进程就是桌面终端
子进程退出的时候,进程中的用户区可以自己释放,但是进程内核区的pcb资源自己无法释放,必须要由父进程来释放子进程的pcb资源,孤儿进程被领养后,这件事父进程就可以代劳,避免了资源的浪费
1 | int main(void) |
僵尸进程
在一个启动的进程中创建子进程,这时就有了父子两个进程,父进程正常运行,子进程先与父进程结,子进程
无法释放自己的PCB资源,需要父进程来做这件事,但是父进程不管,这时候子进程就变成了僵尸进程
不能将僵尸进程看成是一个正常的进程,这个进程已经死亡,用户资源已经被释放了只是还占用着一些内核资源
(PCB)。僵尸进程的出现是由于这个已经死亡的进程的父进程不作为造成的
1 | int main(void) |
上面我们就得到了僵尸进程,消灭僵尸进程的方式是杀死僵尸进程的父进程,这样僵尸进程的资源就被系统回收了
通过kill -9 僵尸进程ID的方式是不能消灭僵尸进程的
进程回收
为了避免僵尸进程的产生,一般我们会在父进程中进行子进程的资源回收,回收方式有两种:
阻塞方式wait()非阻塞方式waitpid()
wait
这是个阻塞函数,如果没有子进程退出,函数会一直阻塞等待,当检测到子进程退出了,该函数阻塞解除回收子进程资源
这个函数被调用一次,只能回收一个子进程的资源,如果多个子进程需要资源回收,函数需要被多次调用
1 | // man 2 wait |
参数:传出参数,通过传递出的信息判断回收的进程是怎么退出的,如果不需要该信息可以指定为NULL
取出整形变量中的数据需要使用一些宏函数WIFEXITED(status):返回1,进程是正常退出的WEXITSTATUS(status)得到进程退出时候的状态码,相当于return后面的数值,或者exit函数的参数WIFSIGNALED(status):返回1,进程是被信号杀死了WTERMSIG(status):获得进程是被哪个信号杀死的,会得到信号的编号
返回值
- 成功:返回被回收的子进程的进程ID
- 失败:-1
- 没有子进程资源可以回收了,函数的阻塞会自动解除,返回-1
- 回收子进程资源的时候出现了异常
演示通过
wait()回收多个子进程资源
1 |
|
waitpid
waitpid()可以控制回收子进程资源的方式是阻塞还是非阻塞,另外还可以通过该函数进行精准打击,精确指定回收
某个或者某一类或者是全部子进程资源
1 | // man 2 waitpid |
参数:
pid-1:回收所有的子进程资源,和 wait () 是一样的,无差别回收,并不是一次性就可以回收多个,也是需要循环回收的大于0:指定回收某一个进程的资源 ,pid 是要回收的子进程的进程 ID0:回收当前进程组的所有子进程 ID小于 -1:pid 的绝对值代表进程组 ID,表示要回收这个进程组的所有子进程资源
status: NULL, 和 wait 的参数是一样的options:控制函数是阻塞还是非阻塞0: 函数是行为是阻塞的 ==> 和 wait 一样WNOHANG: 函数是行为是非阻塞的
返回值
如果函数是非阻塞的,并且子进程还在运行,返回 0
成功:得到子进程的进程 ID
失败:-1
- 没有子进程资源可以回收了,函数如果是阻塞的,阻塞会解除,直接返回 - 1
- 回收子进程资源的时候出现了异常
演示使用
waitpid()阻塞回收多个子进程资源
1 | // 和wait() 行为一样, 阻塞 |
演示通过
waitpid()非阻塞回收多个子进程资源
1 | // 非阻塞处理 |
管道
管道
管道是进程间通信(IPC)的一种方式,管道本质其实就是内核中的一块内存(内核缓冲区)
这块缓冲区中的数据存储在一个环形队列中,因为管道在内核里面,因此我们不能直接对其进行任何操作
管道是通过队列来维护的:
- 管道对应的内核缓冲区大小是固定的,默认为4K(队列能最大能存储4K数据)
- 管道分为两部分:读端和写端(队列的两端),数据从写端进入管道,从读端流出管道
- 管道中的数据只能读一次,做一次读操作之后数据也就没有了(读数据相当于出队列)
- 管道是单工的:数据只能单向流动,数据从写端流向读端
- 对管道的操作(读,写)默认是阻塞的
- 读管道:管道中没有数据,读操作被阻塞,当管道中有数据之后阻塞才能解除
- 写管道:管道被写满了,写数据的操作被阻塞,当管道变为不满的状态,写阻塞解除
管道在内核中,不能直接对其进行操作,我们通过文件IO来操作管道,内核中管道两端分别对应两个文件描述符,通过
写端的文件描述符把数据写入到管道中,通过读端的文件描述符将数据从管道中读出来
1 | // 读管道 |
管道是独立于任何进程的,并且充当了两个进程用于数据通信的载体,只要两个进程能够得到同一个管道的入口
和出口(读端和写端的文件描述符),那么它们之间就可以通过管道进行数据的交互
匿名管道
创建匿名管道
匿名管道是管道的一种,匿名说明这个管道没有名字,但本质是不变的,就是位于内核中的一块内存,匿名管道
拥有上面介绍的管道的所有特性,但是匿名管道只能实现有血缘关系的进程间通信
1 |
|
- 参数:传出参数,需要传递一个整形数组的地址,数组大小为2,也就是说最终会传出两个参数
pipefd[0]:对应管道读端的文件描述符,通过他可以将数据从管道中读出pipefd[1]:对应管道写端的文件描述符,通过它可以将数据写入到管道中
- 返回值:成功返回0,失败返回-1
进程间通信
现考虑使用匿名管道实现下面这个功能
1
2
3
4 需求描述:
在父进程中创建一个子进程, 父子进程分别执行不同的操作:
- 子进程: 执行一个shell命令 "ps aux", 将命令的结果传递给父进程
- 父进程: 将子进程命令的结果输出到终端
需求分析:
子进程中执行shell命令相当于启动一个程序,需要使用
execl()/execlp()execlp("ps", "ps", "aux", NULL)子进程中执行完shell命令直接就在终端输出结果,使用匿名管道将这些信息传递给父进程
子进程将数据写入到管道中
将默认输出到终端的数据写入到管道就需要进行输出的重定向,需要使用
dup2()dup2(fd[1], STDOUT_FILENO)
父进程需要读管道,将从管道中读出的数据打印到终端
父进程最后需要释放子进程资源,防止出现僵尸进程
在使用管道进行进程间通信的注意事项:必须保证数据在管道中管道单向流动
- 父进程中创建了匿名管道,得到两个分配的文件描述符
- 父进程创建子进程,父进程的文件描述符被拷贝,在子进程的文件描述符表中也得到了两个被分配的可以使用
的文件描述符,那么管道中数据的流动就不是单向的了 - 为了避免两个进程都读管道(可能其中某个进程由于读不到数据而阻塞),我们可以关闭进程中用不到的那一端
的文件描述符,这样数据就只能单向的从一端流向另外一端了(关闭父进程的写端,子进程的读端)
1 | // 管道的数据是单向流动的: |
有名管道
创建有名管道
有名管道拥有管道的所有特性,之所以称之为有名是因为有名管道在磁盘上有实体文件,文件类型为p,有名管道
文件大小永远为0,因为有名管道也是将数据存储到内存的缓冲区中,打开这个磁盘上的管道文件就可以得到操作
有名管道的文件描述符,通过文件描述符读写管道存储在内核中的数据有名管道也可以称为
fifo,使用有名管道既可以进行有血缘关系的进程间通信,也可以进行没有血缘关系的进程
间通信。创建有名管道的方式有两个:命令,函数
通过命令
1
mkfifo 有名管道的名字
通过函数
1
int mkfifo(const char *pathname, mode_t mode);
- 参数
pathname:要创建的有名管道的名字mode:文件的操作权限,和open()的第三个参数一个作用,最终权限:mode & ~umask
- 返回值:创建成功返回0,失败返回-1
- 参数
进程间通信
不管是有血缘关系还是没有血缘关系,使用有名管道实现进程间通信的方式是相同的,就是在两个进程中分别以读、写
的方式打开磁盘上的管道文件,得到用于读管道、写管道的文件描述符,就可以调用对应的read() write()进行读写
有名管道操作需要通过 open () 操作得到读写管道的文件描述符,如果只是读端打开了或者只是写端打开了,进程会阻塞在这里不会向下执行,直到在另一个进程中将管道的对端打开,当前进程的阻塞也就解除了。所以当发现进程阻塞在了 open () 函数上不要感到惊讶
写管道的进程
1
2
3
4
5
6
7
8
9
10/*
1. 创建有名管道文件
mkfifo()
2. 打开有名管道文件, 打开方式是 o_wronly
int wfd = open("xx", O_WRONLY);
3. 调用write函数写文件 ==> 数据被写入管道中
write(wfd, data, strlen(data));
4. 写完之后关闭文件描述符
close(wfd);
*/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
int main(void)
{
// 1. 创建有名管道文件
int ret = mkfifo("./testfifo", 0664);
if(ret == -1) {
perror("mkfifo");
exit(0);
}
printf("管道文件创建成功...\n");
// 2. 打开管道文件
// 因为要写管道, 所有打开方式, 应该指定为 O_WRONLY
// 如果先打开写端, 读端还没有打开, open函数会阻塞, 当读端也打开之后, open解除阻塞
int wfd = open("./testfifo", O_WRONLY);
if(wfd == -1) {
perror("open");
exit(0);
}
printf("以只写的方式打开文件成功...\n");
// 3. 循环写管道
int i = 0;
while(i<100) {
char buf[1024];
sprintf(buf, "hello, fifo, 我在写管道...%d\n", i);
write(wfd, buf, strlen(buf));
i++;
sleep(1);
}
close(wfd);
return 0;
}读管道的进程
1
2
3
4
5
6
7
8
9
10/*
1. 这两个进程需要操作相同的管道文件
2. 打开有名管道文件, 打开方式是 o_rdonly
int rfd = open("xx", O_RDONLY);
3. 调用read函数读文件 ==> 读管道中的数据
char buf[4096];
read(rfd, buf, sizeof(buf));
4. 读完之后关闭文件描述符
close(rfd);
*/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(void)
{
// 1. 打开管道文件
// 因为要read管道, so打开方式, 应该指定为 O_RDONLY
// 如果只打开了读端, 写端还没有打开, open阻塞, 当写端被打开, 阻塞就解除了
int rfd = open("./testfifo", O_RDONLY);
if(rfd == -1) {
perror("open");
exit(0);
}
printf("以只读的方式打开文件成功...\n");
// 2. 循环读管道
while(1) {
char buf[1024];
memset(buf, 0, sizeof(buf));
// 读是阻塞的, 如果管道中没有数据, read自动阻塞
// 有数据解除阻塞, 继续读数据
int len = read(rfd, buf, sizeof(buf));
printf("读出的数据: %s\n", buf);
if(len == 0) {
// 写端关闭了, read解除阻塞返回0
printf("管道的写端已经关闭, 拜拜...\n");
break;
}
}
close(rfd);
return 0;
}
管道的读写行为
不管管道是匿名还是有名,在读写的时候,它们表现出的行为是一致的
读管道:需要根据写端的状态进行分析
写端没有关闭(操作管道写端的文件描述符没有被关闭)
如果管道中没有数据 ==> 读阻塞,如果管道中被写入了数据,阻塞解除
如果管道中有数据 ==> 不阻塞,管道中的数据被读完了,继续读管道还会阻塞
写端已经关闭(没有可用的文件描述符可以写管道了)
管道中没有数据 ==> 读端解除阻塞,read函数返回0
管道中有数据 ==> read现将数据读出,数据读完之后返回0,不会阻塞
写管道:需要根据读端的状态进行分析
读端没有关闭
如果管道有存储的空间,一直写数据
如果管道写满了,写操作阻塞,当读端数据读走了,解除阻塞继续写
读端关闭了,管道破裂(异常),进程直接退出
管道的两端默认是阻塞的,管道的读写两端的非阻塞操作是相同的,下面将匿名的读端设置为了非阻塞
1 | // 通过fcntl 修改就可以, 一般情况下不建议修改 |
内存映射
创建内存映射区
如果想要实现进程间通信,可以通过函数创建一块内存映射区,和管道不同的是管道对应的内存空间在内核,而
内存映射区对应的内存空间在进程的用户区(用于加载动态库的那个区域),也就是说进程间通信使用的内存映
射区不是一块,而是在每个进程内部都有一块由于每个进程的地址空间是独立的,各个进程之间也不能直接访问对方的内存映射区,需要通信的进程需要将各自
的内存映射区和同一个磁盘文件进行映射,这样进程之间就可以通过磁盘文件这个唯一的桥梁完成数据的交互了
使用内存映射区既可以用于有血缘关系进程的进程间通信也可以用于没有血缘关系进程的进程间通信
1 |
|
- 参数
addr:从动态库加载区的什么位置开始创建内存映射区,一般指定为NULL,委托内核分配length:创建的内存映射区的大小(字节),实际上这个大小是按照4K的整数倍区分配的prot:对内存映射区的操作权限PROT_READ:读内存映射区PORT_WRITE:写内存映射区- 如果要对映射区有读写权限:
PORT_RAED | PORT_WRITE
flagsMAP_SHARED:多个进程可以共享数据,进行映射区数据同步MAP_PRIVATE:映射区数据是私有的,不能同步给其它进程
fd:文件描述符,对应一个打开的磁盘空间,内存映射区通过这个文件描述符和磁盘文件建立关联offset:磁盘文件的偏移量,文件从偏移到的位置开始进行数据映射,使用这个参数需要注意两个问题- 偏移量必须是4K的整数倍,写0代表不偏移
- 这个参数必须是大于0的
- 返回值
- 成功:返回一个内存映射区的起始地址
- 失败:
MAP_FAILED((void *)-1)
mmap()参数较多,在使用该函数创建用于进程间通信的内存映射区的时候,各参数的指定都有一些注意事项
1
2
3
4
5
6
7 1. 第一个参数 addr 指定为 NULL 即可
2. 第二个参数 length 必须要 > 0
3. 第三个参数 prot,进程间通信需要对内存映射区有读写权限,因此需要指定为:PROT_READ | PROT_WRITE
4. 第四个参数 flags,如果要进行进程间通信, 需要指定 MAP_SHARED
5. 第五个参数 fd,打开的文件必须大于0,进程间通信需要文件操作权限和映射区操作权限相同
- 内存映射区创建成功之后, 关闭这个文件描述符不会影响进程间通信
6. 第六个参数 offset,不偏移指定为0,如果偏移必须是4k的整数倍
内存映射区使用之后也需要释放:
1 int munmap(void *addr, size_t length);
- 参数
addr:mmap()的返回值,创建的内存映射区的起始地址length:和mmap()第二个参数相同即可- 返回值:函数调用成功返回0,失败返回-1
进程间通信
操作内存映射区和操作管道是不一样的,得到内存映射区之后是直接对内存地址进行操作,管道是通过文件描述符读写队列中的数据,管道的读写是阻塞的,内存映射区的读写是非阻塞的。内存映射区创建成功之后,得到了映射区内存的起始地址,使用相关的内存操作函数读写数据就可以了
有血缘关系
由于创建子进程会发生虚拟地址空间的复制,那么在父进程中创建的内存映射区也会被复制到子进程中,这样在子进程里边就可以直接使用这块内存映射区了,所以对于有血缘关系的进程,进行进程间通信是非常简单的
1 | /* |
无血缘关系
对于没有血缘关系的进程间通信,需要在每个进程中分别创建内存映射区,但是这些进程的内存映射区必须要关联相同的磁盘文件,这样才能实现进程间的数据同步
进程A的测试代码
1 | void mmap_out(void) |
进程B的测试代码
1 | void mmap_in(void) |
拷贝文件
使用内存映射区除了可以实现进程间通信,也可以进行文件的拷贝,使用这种方式拷贝文件可以减少程序猿的工作量,我们只需要负责创建内存映射区和打开磁盘文件,关于文件中的数据读写就无需关心了
使用内存映射拷贝文件思路
- 打开被拷贝文件,得到文件描述符 fd1,并计算出这个文件的大小 size
- 创建内存映射区 A 并且和被拷贝文件关联,也就是和 fd1 关联起来,得到映射区地址 ptrA
- 创建新文件,得到文件描述符 fd2,用于存储被拷贝的数据,并且将这个文件大小拓展为 size
- 创建内存映射区 B 并且和新创建的文件关联,也就是和 fd2 关联起来,得到映射区地址 ptrB
- 进程地址空间之间的数据拷贝,memcpy(ptrB, ptrA,size),数据自动同步到新建文件中
- 关闭内存映射区
1 |
|
共享内存
创建、打开共享内存
shmget
在使用共享内存之前必须要先做一些准备工作,如果共享内存不存在就需要先创建出来,如果已经存在了就需要先打开
这块共享内存
1 |
|
- 参数
key: 类型 key_t 是个整形数,通过这个key可以创建或者打开一块共享内存,该参数的值一定要大于0size: 创建共享内存的时候,指定共享内存的大小,如果是打开一块存在的共享内存,size 是没有意义的shmflg:创建共享内存的时候指定的属性IPC_CREAT: 创建新的共享内存,如果创建共享内存,需要指定对共享内存的操作权限,比如:IPC_CREAT | 0664IPC_EXCL: 检测共享内存是否已经存在了,必须和 IPC_CREAT 一起使用
- 返回值:共享内存创建或者打开成功返回标识共享内存的唯一的 ID,失败返回 - 1
函数使用举例:
创建一块大小为4K的共享内存
1 | shmget(100, 4096, IPC_CREAT | 0664); |
创建一块大小为4K的共享内存,并且检测其是否存在
1 | // 如果共享内存已经存在, 共享内存创建失败, 返回-1, 可以perror() 打印错误信息 |
打开一块已经存在的共享内存
1 | // 函数参数虽然指定了大小和IPC_CREAT, 但是都不起作用, 因为共享内存已经存在, 只能打开, 参数4096也没有意义 |
打开一块共享内存,如果不存在就创建
1 | shmget(100, 4096, IPC_CREAT | 0664); |
ftok
shmget ( ) 函数的第一个参数是一个大于 0 的正整数,如果不想自己指定可以通过 ftok ( ) 函数直接生成这个 key 值
1 | // ftok函数原型 |
- 参数
pathname:当前操作系统中一个存在的路径proj_id:这个参数只用到了 int 中的一个字节,传参的时候要将其作为 char 进行操作,取值范围: 1-255
- 返回值:函数调用成功返回一个可用于创建、打开共享内存的key值,调用失败返回-1
1 | // 根据路径生成一个key_t |
关联和解除关联
shmat
创建 / 打开共享内存之后还必须和共享内存进行关联,这样才能得到共享内存的起始地址,通过得到的内存地址
进行数据的读写操作
1 | void *shmat(int shmid, const void *shmaddr, int shmflg); |
- 参数
shmid: 要操作的共享内存的 ID, 是shmget()函数的返回值shmaddr: 共享内存的起始地址,用户不知道,需要让内核指定,写 NULLshmflg: 和共享内存关联的对共享内存的操作权限SHM_RDONLY: 读权限,只能读共享内存中的数据0: 读写权限,可以读写共享内存数据
- 返回值:关联成功,返回值共享内存的起始地址,关联失败返回 (void *) -1
shmdt
当进程不需要再操作共享内存,可以让进程和共享内存解除关联,另外如果没有执行该操作,进程退出之后,结束的
进程和共享内存的关联也就自动解除了
1 | int shmdt(const void *shmaddr); |
- 参数:
shmdt()函数的返回值,共享内存的起始地址 - 返回值:关联解除成功返回0,失败返回-1
删除共享内存
shmctl
shmctl()函数是一个多功能函数,可以设置、获取共享内存的状态也可以将共享内存标记为删除状态
当共享内存被标记为删除状态之后,并不会马上被删除,直到所有的进程全部和共享内存解除关联,共享内存才会被删除
因为通过shmctl()函数只是能够标记删除共享内存,所以在程序中多次调用该操作是没有关系的
1 | // 共享内存控制函数 |
- 参数
shmid:要操作的共享内存的 ID, 是shmget()函数的返回值cmd:要做的操作IPC_STAT:得到当前共享内存的状态IPC_SET:设置共享内存的状态IPC_DMID:标记共享内存要被删除了
bufcmd == IPC_STAT:作为传出参数,会得到共享内存的相关属性信息cmd == IPC_SET:作为传入参,将用户的自定义属性设置到共享内存中cmd == IPC_RMID:buf就没意义了,这时候buf指定为NULL即可
- 返回值:函数调用成功返回值大于等于 0,调用失败返回 - 1
相关shell命令
查看系统中共享内存的详细信息
1 | ipcs -m |
使用ipcrm命令可以标记删除某块共享内存
1 | # key == shmget |
共享内存状态
1 | // 参数 struct shmid_ds 结构体原型 |
通过 shmctl() 我们可以得知,共享内存的信息是存储到一个叫做 struct shmid_ds 的结构体中,其中有一个非常重要的成员
叫做 shm_nattch,在这个成员变量里边记录着当前共享内存关联的进程的个数,一般将其称之为引用计数。当共享内存被
标记为删除状态,并且这个引用计数变为 0 之后共享内存才会被真正的被删除掉
当共享内存被标记为删除状态之后,共享内存的状态也会发生变化,共享内存内部维护的 key 从一个正整数变为 0,其属性
从公共的变为私有的。这里的私有是指只有已经关联成功的进程才允许继续访问共享内存,不再允许新的进程和这块共享内
存进行关联了
进程间通信
使用共享内存实现进程间通信
1 | 1. 调用linux的系统API创建一块共享内存 |
写共享内存的进程代码:
1 | void shm_out(void) |
读共享内存的进程代码:
1 | void shm_in(void) |
shm和mmap的区别
共享内存 内存映射区都可以实现进程间通信
- 实现进程间通信的方式
shm:多个进程只需要一块共享内存就够了,共享内存不属于进程,需要和进程关联才能使用- 内存映射区:位于每个进程的虚拟地址空间中,并且需要关联同一个磁盘文件才能实现进程间数据通信
- 效率
shm:直接对内存操作,效率高- 内存映射区:需要内存和文件之间的数据同步,效率低
- 生命周期
- 内存映射区:进程退出,内存映射区也就没有了
shm:进程退出对共享内存没有影响,调用相关函数 / 命令 / 关机才能删除共享内存
- 数据的完整性:突发状态下数据能不能被保存下来
- 内存映射区:可以完整的保存数据,内存映射区数据会同步到磁盘文件
shm:数据存储在物理内存中,断电之后系统关闭,内存数据也就丢失了
守护进程
守护进程(Daemon Process),也就是通常说的 Daemon 进程(精灵进程),是 Linux 中的后台服务进程。它是一个生
存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以 d 结尾的名字
进程组
多个进程的集合就是进程组,这个组中必须有一个组长,组长就是进程组中的第一个进程,组长以外的都是普通的
成员,每个进程组都有一个唯一的组 ID,进程组的 ID 和组长的 PID 是一样的进程组中的成员是可以转移的,如果当前进程组中的成员被转移到了其他的组,或者进制中的所有进程都退出了,
那么这个进程组也就不存在了。如果进程组中组长死了,但是当前进程组中有其他进程,这个进程组还是继续存在
得到当前进程所在的进程组的组ID
1 | pid_t getpgrp(void); |
获取指定的进程所在的进程组的组ID,参数pid就是指定的进程
1 | pid_t getpgid(pid_t pid); |
将某个进程移动到其他进程组中或者创建新的进程组
1 | int setpgid(pid_t pid, pid_t pgid); |
- 参数
pid:某个进程的进程IDpgid:某个进程组的组ID- 如果 pgid 对应的进程组存在,pid 对应的进程会移动到这个组中,pid != pgid
- 如果 pgid 对应的进程组不存在,会创建一个新的进程组,因此要求 pid == pgid, 当前进程就是组长了
- 返回值:函数调用成功返回 0,失败返回 - 1
会话
会话 (session) 是由一个或多个进程组组成的,一个会话可以对应一个控制终端,也可以没有。一个普通的进程可以调
用 setsid() 函数使自己成为新 session 的领头进程(会长),并且这个 session 领头进程还会被放入到一个新的进程组
1 | // 获取某个进程所属的会话ID |
注意事项:
- 调用这个函数的进程不能是组长进程,通过
先 fork () 创建子进程,终止父进程,让子进程调用这个函数
保证这个函数调用成功- 如果调用这个函数的进程不是进程组长,会话创建成功
- 这个进程会变成当前会话中的第一个进程,同时也会变成新的进程组的组长
- 该函数调用成功之后,当前进程就脱离了控制终端,因此不会阻塞终端
创建守护进程
如果要创建一个守护进程,标准步骤如下:
创建子进程,让父进程退出
- 因为父进程有可能是组长进程,不符合条件,也没有什么利用价值,退出即可
- 子进程没有任何职务,目的是让子进程最终变成一个会话,最终就会得到守护进程
通过子进程创建新的会话,调用函数
setsid(),脱离控制终端,变成守护进程改变当前进程的工作目录(可选项)
某些文件系统可以被卸载,比如: U 盘,移动硬盘,进程如果在这些目录中运行,运行期间这些设备被卸载了,
运行的进程也就不能正常工作了修改当前进程的工作目录需要调用函数
chdir()1
int chdir(const char *path);
重新设置文件的掩码(可选项)
掩码: umask, 在创建新文件的时候需要和这个掩码进行运算,去掉文件的某些权限
设置掩码需要使用函数
umask()1
mode_t umask(mode_t mask);
关闭 / 重定向文件描述符
启动一个进程,文件描述符表中默认有三个被打开了,对应的都是当前的终端文件
因为进程通过调用 setsid () 已经脱离了当前终端,因此关联的文件描述符也就没用了,可以关闭
1
2
3close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);重定向文件描述符 (和关闭二选一): 改变文件描述符关联的默认文件,让他们指向一个特殊的文件 /dev/null,
只要把数据扔到这个特殊的设备文件中,数据被被销毁了1
2
3
4
5int fd = open("/dev/null", O_RDWR);
// 重定向之后, 这三个文件描述符就和当前终端没有任何关系了
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
守护进程的应用
写一个守护进程,每隔 2s 获取一次系统时间,并将得到的时间写入到磁盘文件中
1 |
|
信号
Linux中的信号是一种消息处理机制,本质上是一个整数,信号在系统中的优先级非常高
在Linux中的很多常规操作都会有相关的信号产生
- 键盘
shell命令- 函数调用
- 对硬件的非法访问
信号
信号编号
1 | kill -l |
| 编号 | 信号 | 事件 | 默认动作 |
|---|---|---|---|
| 1 | SIGHUP | 用户退出 shell 时,由该 shell 启动的所有进程将收到这个信号 | 终止进程 |
| 2 | SIGINT | <Ctrl+C>,用户终端向正在运行中的由该终端启动的程序发出此信号 | 终止进程 |
| 3 | SIGQUIT | <ctrl+\>,用户终端向正在运行中的由该终端启动的程序发出些信号 | 终止进程 |
| 4 | SIGILL | CPU 检测到某进程执行了非法指令 | 终止进程并产生 core 文件 |
| 5 | SIGTRAP | 该信号由断点指令或其他 trap 指令产生 | 终止进程并产生 core 文件 |
| 6 | SIGABRT | 调用 abort 函数时产生该信号 | 终止进程并产生 core 文件 |
| 7 | SIGBUS | 非法访问内存地址,包括内存对齐出错 | 终止进程并产生 core 文件 |
| 8 | SIGFPE | 在发生致命的运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为 0 等所有的算法错误 | 终止进程并产生 core 文件 |
| 9 | SIGKILL | 无条件终止进程 | 终止进程,可以杀死任何进程 |
| 10 | SIGUSE1 | 用户定义的信号。即程序员可以在程序中定义并使用该信号 | 终止进程 |
| 11 | SIGSEGV | 指示进程进行了无效内存访问 (段错误) | 终止进程并产生 core 文件 |
| 12 | SIGUSR2 | 另外一个用户自定义信号,程序员可以在程序中定义并使用该信号 | 终止进程 |
| 13 | SIGPIPE | Broken pipe 向一个没有读端的管道写数据 | 终止进程 |
| 14 | SIGALRM | 定时器超时,超时的时间 由系统调用 alarm 设置 | 终止进程 |
| 15 | SIGTERM | 可以被阻塞和终止,通常用来要示程序正常退出 | 终止进程 |
| 16 | SIGSTKFLT | Linux 早期版本出现的信号,现仍保留向后兼容 | 终止进程 |
| 17 | SIGCHLD | 子进程结束时,父进程会收到这个信号 | 忽略这个信号 |
| 18 | SIGCONT | 如果进程已停止,则使其继续运行 | 继续 / 忽略 |
| 19 | SIGSTOP | 停止进程的执行。信号不能被忽略,处理和阻塞 | 为终止进程 |
| 20 | SIGTSTP | <ctrl+z>,停止终端交互进程的运行 | 暂停进程 |
| 21 | SIGTTIN | 后台进程读终端控制台 | 暂停进程 |
| 22 | SIGTTOU | 该信号类似于 SIGTTIN,在后台进程要向终端输出数据时发生 | 暂停进程 |
| 23 | SIGURG | 套接字上有紧急数据时,向当前正在运行的进程发出些信号,报告有紧急数据到达。如网络带外数据到达 | 忽略该信号 |
| 24 | SIGXCPU | 进程执行时间超过了分配给该进程的 CPU 时间 ,系统产生该信号并发送给该进程 | 终止进程 |
| 25 | SIGXFSZ | 超过文件的最大长度设置 | 终止进程 |
| 26 | SIGVTALRM | 虚拟时钟超时时产生该信号。类似于 SIGALRM,但是该信号只计算该进程占用 CPU 的使用时间 | 终止进程 |
| 27 | SGIPROF | 类似于 SIGVTALRM,它不公包括该进程占用 CPU 时间还包括执行系统调用时间 | 终止进程 |
| 28 | SIGWINCH | 窗口变化大小时发出 | 忽略该信号 |
| 29 | SIGIO | 此信号向进程指示发出了一个异步 IO 事件 | 忽略该信号 |
| 30 | SIGPWR | 关机 | 终止进程 |
| 31 | SIGSYS | 无效的系统调用 | 终止进程并产生 core 文件 |
| 34~64 | SIGRTMIN ~ SIGRTMAX | LINUX 的实时信号,它们没有固定的含义(可以由用户自定义) | 终止进程 |
查看信号信息
1 | man 7 signal |
五种对产生信号的默认处理动作
Term:信号将进程终止Ign:信号产生之后默认被忽略了Core:信号将进程终止,并且生成一个core文件(一般用于gdb调试)Stop:信号将会暂停进程的运行Cont:信号会让暂停的进程继续运行
9号信号和19号信号不能被捕捉、阻塞、忽略
- 9号信号:无条件杀死进程
- 19号信号:无条件暂停进程
信号的状态
Linux的信号有三种状态,分别为:产生、未决、递达
产生:键盘输入、函数调用、执行shell命令、对硬件进行非法访问都会产生信号未决:信号产生了,但是这个信号还没有被处理掉,这个期间信号的状态称之为未决状态递达:信号被处理了(被某个进程处理掉)
信号相关函数
产生信号的常用函数
kill raise abort
发送相关的信号给对应的进程
kill发送指定的信号到指定的进程1
2// 给某一个进程发送一个信号
int kill(pid_t pid, int sig);- 参数:
pid进程ID,sig要发送的信号
1
2kill(getpid(), 9); // kill self
kill(getppid(), 10);// kill parent- 参数:
raise给当前进程发送指定的信号1
2// 给自己发送某一个信号
int raise(int sig);abort给当前进程发送一个固定信号(SIGABRT)1
2// 这是一个中断函数, 调用这个函数, 发送一个固定信号 (SIGABRT), 杀死当前进程
void abort(void);
定时器
alarm:alarm ( ) 函数只能进行单次定时,定时完成发射出一个信号1
unsigned int alarm(unsigned int seconds);
- 参数:倒计时seconds,倒计时完成发送SIGALRM,当前进程会收到这个信号
- 返回值:大于0表示倒计时还剩多少秒,0表示倒计时完成
real = user + sys + 消耗的时间(频率的从用户区到内核区进程切换)setitimer:setitimer ( ) 函数可以进行周期性定时,每触发一次定时器就会发射出一个信号1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 这个函数可以实现周期性定时, 每个一段固定的时间, 发出一个特定的定时器信号
struct itimerval {
struct timeval it_interval; /* 时间间隔 */
struct timeval it_value; /* 第一次触发定时器的时长 */
};
// 这个结构体表示的是一个时间段: tv_sec + tv_usec
struct timeval {
time_t tv_sec; /* 秒 */
suseconds_t tv_usec; /* 微妙 */
};
int setitimer(int which, const struct itimerval *new_value,
struct itimerval *old_value);参数:
which:定时器使用什么样的计时法则ITIMER_REAL:自然计时法,发送SIGALRMITIMER_VIRTUAL:只计算用户区运行的时间,发送SIGVTALRMITIMER_PROF:只计算内核运行时间,发送SIGPROF
new_value:给定时器设置的定时信息,传入参数old_value:上一次给定时器设置的定时信息,传出参数,如果不需要可以指定为NULL
信号集
阻塞、未决信号集
在PCB中有两个非常重要的信号集,一个称为阻塞信号集,另一个称为未决信号集
信号的
未决是一种状态,指的是从信号的产生到信号被处理前的一段时间信号的
阻塞是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生信号的阻塞就是让系统暂时保留信号留待以后发送
阻塞信号集和未决信号集在内核中的结构是相同的,它们都是一个整形数组 (被封装过的), 一共 128 字节 (int [32] == 1024 bit),1024 个标志位,其中前 31 个标志位,每一个都对应一个 Linux 中的标准信号,通过标志位的值来标记当前信号在信号集中的状态
1 | # 上图对信号集在内核中存储的状态的描述 |
- 在阻塞信号集里,描述这个信号有没有被阻塞
- 默认情况下没有信号是被阻塞的,因此信号对应的标志位的值是0
- 如果某个信号被设置为了阻塞状态,这个信号对应的标志位被设置为1
- 在未决信号集里,描述信号是否处于未决状态
- 如果这个信号被阻塞了,不能处理,这个信号对应的标志位被设置为1
- 如果这个信号的阻塞被解除了,未决信号集中的这个信号马上就被处理了,这个信号对应的标志位被为0
- 如果这个信号没有阻塞,信号产生之后直接被处理,因此不会在未决信号集中做任何记录
信号集函数
1 | int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); |
参数
howSIG_BLOCK:将set集合中的数据追加到阻塞信号集中SIG_UNBLOCK:将set集合中的信号在阻塞信号集中解除阻塞SIG_SETMASK:将set集合中的数据覆盖内核的阻塞信号集数据
set:信号集oldset:将设置之前的阻塞信号集传出
返回值:调用成功返回0,失败返回1
初始化
sigset_t类型的参数
1 | // 将set集合中所有的标志位设置为0 |
设置某个信号阻塞,当该信号产生后,内核会将这个信号的未决状态记录到未决信号集中,当阻塞的信号被解除阻塞未决信号集中的信号随之被处理,内核再次修改未决信号集将该信号的状态修改为递达状态
1 | // 读一下这个集合就指定哪个信号是未决状态 |
演示:
1 |
|
信号捕捉
Linux中的每个信号都会有对应的默认处理行为,如果想要忽略这个信号或者修改某个信号的默认行为就需要在程序中捕获该信号
signal
使用signal()可以捕捉进程中产生的信号,并且修改捕捉到的函数的行为,这个信号的自定义处理动作是一个回调函数,内核通过signal()得到这个回调函数的地址,在信号产生之后该函数会被内核调用
1 | sighandler_t signal(int signum, sighandler_t handler); |
参数
signum:需要捕捉的信号handler:信号捕捉之后的处理动作,是一个函数指针1
typedef void (*sighandler_t)(int);
该函数由我们编写,供内核调用
sigaction
1 | int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact); |
- 参数
signum:要捕捉的信号act:捕捉到信号之后的处理动作oldact:上一次调用该函数进行信号捕捉设置的信号处理动作,一般指定为NULL
- 返回值:调用成功返回0,失败返回1
1 | struct sigaction { |
sa_handler:函数指针,指向的函数就是捕捉到的信号的处理动作sa_sigaction:函数指针,指向的函数就是捕捉到的信号的处理动作sa_mask:在信号处理函数执行期间,临时屏蔽某些信号,将要屏蔽的信号设置到集合中即可- 当前处理函数执行完毕,临时屏蔽自动解除
- 假设在这个集合中不屏蔽任何信号,默认也会屏蔽一个
sa_flags:使用哪个函数指针指向的函数处理捕捉到的信号0:使用sa_handlerSA_SIGINFO:使用sa_sigaction
sa_restorer:被废弃的成员
SIGCHLD信号
当子进程退出、暂停、从暂停恢复运行的时候,在子进程中会产生一个
SIGCHLD信号,并将其发送给父进程,但是父进程收到这个信号默认就忽略了。我们可以在父进程成基于这个信号来回收子进程的资源
1 |
|
线程
线程概述
线程是轻量级的进程,在Linux下线程的本质仍然是进程
进程是资源分配的最小单位,线程是操作系统调度执行的最小单位
进程有自己独立的地址空间,多个线程共用一个地址空间
- 线程更加节省系统资源,效率可能会更高
- 在一个地址空间中多个线程独享:每个线程都有属于自己的栈区、寄存器
- 在一个地址空间中多个线程共享:代码段、堆区、全局数据区、文件描述符表都是线程共享的
线程是程序的最小执行单位,进程是操作系统中最小的资源分配单位
- 每个进程对应一个虚拟地址空间,一个进程只能抢一个CPU时间片
- 一个地址空间中可以划分出多个线程,在有效的资源基础上,能够抢更多的CPU时间
CPU的调度和切换:线程的上下文切换比进程要快的多
任务从保存到再次加载这个过程就是一次上下文切换
线程更加廉价,启动速度快,退出也快,对系统的资源冲击小
合理控制线程的个数:
- 文件IO操作:线程个数 = 2 * CPU核心数
- 复杂算法:线程个数 = CPU核心数
创建线程
线程函数
每个线程都有一个唯一的线程ID,ID类型为
pthread_t
1 | pthread_t pthread_self(void); // 获取当前线程的线程ID |
1 |
|
- 参数
thread:传出参数,线程创建成功会将线程ID写入到这个指针指向的内存attr:线程的属性,一般情况下使用默认属性,NULLstart_routine:函数指针,创建的子进程的处理动作,也就是该函数在子线程中指向arg:作为实参传递到start_routine指针指向的函数内部
- 返回值:线程创建成功返回0,创建失败返回对应的错误号
创建线程
1 |
|
虚拟地址空间的生命周期和主线程是一样的,与子线程无关
线程退出
线程退出而虚拟地址空间不释放(针对主线程),使用线程退出函数
只要调用该函数线程马上退出,不会影响到其它线程的正常运行,不管在子线程或者主线程都可以使用
1 | void pthread_exit(void *retval); |
- 参数:线程退出时携带的数据,当前子线程的主线程会得到该数据,不需要就指定为NULL
线程回收
线程函数
线程和进程一样,子线程退出的时候其内核资源主要由主线程回收,线程库中提供的线程回收函数叫做pthread_join(),这个函数是一个阻塞函数,如果还有子线程在运行,调用该函数就会阻塞,子线程退出函数解除阻塞进行资源的回收,函数被调用一次,只能回收一个子线程,如果有多个子线程则需要循环进行回收
1 | int pthread_join(pthread_t thread, void **retval); |
- 参数:
pthread:要被回收的子线程的进程IDretval:二级指针,指向一级指针的地址,是一个传出参数,这个地址中存储了pthread_exit()传递出的数据,如果不需要就指定为NULL
- 返回值:线程回收成功返回0,回收失败返回错误号
回收子线程数据
在子线程退出的时候可以使用
pthread_exit()的参数将数据传出,在回收这个子线程的时候通过pthread_join()的第二个参数来接收子线程传递的数据
- 使用子线程栈
1 |
|
如果多个线程共用一个虚拟地址空间,每个线程在栈区都有一块属于自己的内存,相当于栈区被这几个线程平分了,当线程退出,线程在栈区的内存也就被回收了,因此随着子线程的退出,写入到栈区的数据也就被释放了
- 使用全局变量
位于同一虚拟地址空间的线程,虽然不能共享栈区数据,但是可以共享全局数据区和堆区数据,子进程在退出的时候可以将数据存储到全局变量中、静态变量或者堆内存中
1 |
|
- 使用主线程栈
位于同一个地址空间的多个线程可以相互访问对方栈空间上的数据,主线程一般最后退出,我们可以将子线程返回的数据保存到主线程的栈区内存中:
1 |
|
线程分离
某些情况下,主线程有属于自己的业务处理流程,如果让主线程负责子线程的资源回收,调用
pthread_join(),子线程不退出主线程就一直被阻塞,主线程任务就不能执行了
调用pthread_detach()线程分离函数之后,子线程和主线程分离,当子线程退出的时候,其占用的内核资源就被系统其它进程接管并回收了。线程分离之后在主线程中使用pthread_join()就回收不到子线程资源了
1 | int pthread_detach(pthread_t thread); |
1 |
|
其它线程函数
线程取消
线程取消就是在一个线程中杀死另一个线程
- 在线程A中调用线程取消函数
pthread_cancel,指定杀死线程B,这时候线程B还没死- 在线程B中进行一次系统调用(从用户区切换到内核区),否则线程B可以一直运行
1 | int pthread_cancel(pthread_t thread); |
- 参数:要杀死的线程的线程ID
- 返回值:调用成功返回0,调用失败返回错误号
1 |
|
- 直接调用Linux系统函数
- 调用标准C库函数,为了实现某些功能,在Linux平台下标准C库函数会调用相关的系统函数
线程ID比较
由于跨平台的原因,使用线程比较函数来比较两个线程ID
1 | int pthread_equal(pthread_t t1, pthread_t t2); |
- 参数:要比较线程的线程ID
- 返回值:两个线程ID相等返回非0,不相等返回0
线程同步
所谓同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行
线程同步概念
为什么要同步
如果线程 A 执行这个过程期间就失去了 CPU 时间片,线程 A 被挂起了最新的数据没能更新到物理内存。线程 B 变成运行态之后从物理内存读数据,很显然它没有拿到最新数据,只能基于旧的数据往后数,然后失去 CPU 时间片挂起。线程 A 得到 CPU 时间片变成运行态,第一件事儿就是将上次没更新到内存的数据更新到内存,但是这样会导致线程 B 已经更新到内存的数据被覆盖,活儿白干了,最终导致有些数据会被重复数很多次。
同步方式
对于多线程访问共享数据出现数据混乱的问题,需要进行线程同步。常用的线程同步方式有四种:互斥锁、读写锁、条件变量、信号量
共享资源也称为临界资源,结合上下文代码,得到临界区
在临界区上边添加加锁函数,对临界区加锁
哪个线程调用,就会把这把锁锁上,其它线程就只能阻塞在锁上了
在临界区的下边添加解锁函数,对临界区解锁
出临界区的线程会将锁定的那把锁打开,其他抢到锁的线程就可以进入到临界区
通过锁机制能保证临界区代码最多只能同时有一个线程访问,这样并行访问就变成串行访问了
互斥锁
互斥锁函数
好处解决共享资源混乱,坏处效率降低,并行变串行
1 | pthread_mutex_t mutex; // 互斥锁类型 |
一般情况下每一个共享数据对应一把互斥锁,锁的个数和线程的个数无关
1 | // 初始化互斥锁 |
- 参数:
mutex:互斥锁变量的地址,attr:互斥锁的属性,一般默认NULL
1 | int pthread_mutex_lock(pthread_mutex_t *mutex); // 加锁 |
首先会判断mutex互斥锁中的状态是不是锁定状态
- 没有被锁定,是打开的吗,这个线程可以加锁成功,这个锁会记录是哪个线程加锁成功
- 被锁定了,其他线程加锁失败,这些线程都会阻塞在这把锁上
- 当这把锁被解开之后,这些阻塞在锁上的线程就解除阻塞了,并且这些线程是通过竞争的方式对这把锁加锁,没抢到锁的进程继续阻塞
1 | int pthread_mutex_trylock(pthread_mutex_t *mutex); // 尝试加锁 |
- 没有被锁定,线程加锁成功
- 被锁定了,调用该函数的线程不会被阻塞,加锁失败直接返回错误号
1 | int pthread_mutex_unlock(pthread_mutex_t *mutex); // 对互斥锁解锁 |
- 哪个线程加的锁,哪个线程才能解锁
互斥锁使用
1 |
|
死锁
如果线程死锁:所有的线程都被阻塞,并且线程的阻塞是无法解开的
造成死锁的场景有以下几种:
加锁之后忘记解锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18void func()
{
...
pthread_mutex_lock(&mutex);
...
// 忘记解锁
}
void func()
{
...
pthread_mutex_lock(&mutex);
...
if(xxx)
return ; // 函数提前终止了
pthread_mutex_lock(&mutex);
}重复加锁,造成死锁
1
2
3
4
5
6
7void func()
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
....
pthread_mutex_unlock(&mutex);
}在程序中有多个共享资源,因此有很多把锁,随意加锁,导致互相被阻塞
在使用多线程时,如何避免死锁呢
- 避免多次加锁
- 对共享资源访问完毕之后,一定要解锁,或者在加锁时使用
trylock - 如果程序中有多把锁,可以控制对锁的访问顺序(顺序访问共享资源,但在有些情况下做不到),也可以在对其他互斥锁做加锁操作之前,先释放当前线程拥有的互斥锁
- 项目程序中可以引入一些专门用于死锁检测的模块
读写锁
读写锁函数
读写锁是互斥锁的升级版,在做读写操作的时候可以提高程序的执行效率,如果所有的线程都是做读操作,那么读是并行的,但是使用互斥锁,读操作是串行的
1 | pthread_rwlock_t rwlock; |
该锁既可以锁定读操作,也可以锁定写操作
- 锁的状态:锁定 / 打开
- 锁定的是什么操作:读操作 / 写操作,使用读写锁锁定了读操作,需要先解锁才能去锁定写操作,反之亦然
- 哪个线程将这把锁锁上了
读写锁的特点:
- 使用读写锁的读锁锁定了临界区,线程对临界区的访问是并行的,读锁是共享的
- 使用读写锁的写锁锁定了临界区,线程对临界区的访问是串行的,写锁是独占的
- 使用读写锁分别对两个临界区加了读锁和写锁,两个线程要同时访问这两个临界区,访问写锁临界区的线程继续执行,访问读锁临界区的线程阻塞,
写锁比读锁的优先级高
对共享资源有读有写,并且对共享资源的读操作更多,读写锁会更有优势
1 | pthread_rwlock_t rwlock; |
- 参数:
rwlock:读写锁的地址(传出参数),attr:读写锁属性,一般使用默认属性(NULL)
1 | // 在程序中对读写锁加读锁, 锁定的是读操作 |
- 如果读写锁是打开的,加锁成功;如果读写锁锁定了读操作,调用该函数仍可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数的线程会被阻塞
- 与第一个函数的区别在于:如果读写锁已经锁定了写操作,调用这个函数加锁失败,对应线程不会被阻塞,可以在程序中对函数返回值进行判断,添加加锁失败后的处理动作
1 | // 在程序中对读写锁加写锁, 锁定的是写操作 |
- 如果读写锁是打开的,加锁成功;如果读写锁已经锁定了读操作或者写操作,调用该函数的线程会被阻塞
- 与第一个函数的区别在于:如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数加锁失败,但是线程不会阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作
1 | // 解锁, 不管锁定了读还是写都可用解锁 |
读写锁使用
8个线程操作同一个全局变量,3个线程不定时写同一全局资源,5个线程不定时读同一全局资源
1 |
|
条件变量
条件变量函数
严格意义上条件变量不是处理线程同步,而是进行线程的阻塞。如果在多线程程序中只使用条件变量无法实现线程的同步,必须要配合互斥锁来使用;条件变量和互斥锁都能阻塞线程,二者区别如下:
- 互斥锁是一个线程加锁,其它所有线程阻塞
- 条件变量是满足条件才阻塞,条件不满足多个线程是可以同时进入临界区的
一般情况下条件变量用于处理生产者和消费者模型,并且配合互斥锁使用
1 | pthread_cond_t cond; |
被条件变量阻塞的线程的线程信息会被记录到这个变量中,以便在解除阻塞的时候使用
1 | pthread_cond_t cond; |
- 参数:
cond:条件变量的地址,attr:条件变量的属性(默认NULL)
1 | // 线程阻塞函数, 哪个线程调用这个函数, 哪个线程就会被阻塞 |
该函数需要一个互斥锁参数,这个互斥锁主要用于线程同步,让线程顺利进入临界区,避免出现共享资源的数据混乱
- 在阻塞线程时,如果线程已经对互斥锁mutex上锁,那么会将这把锁打开,避免死锁
- 当线程解除阻塞的时候,函数内部会帮助这个线程再次将这个mutex互斥锁锁上,继续向下访问临界区
1 | // 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示 |
第三个参数表示线程阻塞的时长
1 | time_t mytim = time(NULL); // 1970.1.1 0:0:0 到当前的总秒数 |
1 | // 唤醒阻塞在条件变量上的线程, 至少有一个被解除阻塞 |
调用上面两个函数中的任意一个,都可以唤醒被pthread_cond_wait或者pthread_cond_timewait阻塞的线程;区别在于pthread_cond_signal是唤醒至少一个被阻塞的线程(总个数不定),pthread_cond_broadcast是唤醒所有被阻塞的线程
生产者和消费者
生产者和消费者模型的组成:
生产者线程 -> 若干个
生产商品或者任务放入到任务队列中
任务队列满了就阻塞,不满的时候就工作
通过一个生产者的条件变量控制生产者线程阻塞和非阻塞
消费者线程 -> 若干个
- 读任务队列,将任务或者数据取出
- 任务队列中有数据就消费,没有数据就阻塞
- 通过一个消费者的条件变量控制消费者线程阻塞和非阻塞
队列 -> 存储任务 / 数据,对应一块内存,为了读写访问可以通过一个数据结构维护这块内存
- 可以是数组、链表,也可以使用 stl 容器:queue /stack/list/vector
1 |
|
信号量
信号量函数
信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作
信号量主要阻塞线程,不能完全保证线程安全,如果要保证线程安全,需要信号量和互斥锁一起使用
信号量和条件变量一样用于处理生产者和消费者模型,用于阻塞生产者线程或者消费者线程的运行
1 |
|
Linux提供的信号量操作函数原型:
1 | // 初始化信号量 |
- 参数
sem:信号量变量地址pshared:0,线程同步;非0,进程同步value:初始化当前信号量拥有的资源数(>= 0),如果资源数为0线程阻塞
1 | // 参数 sem 就是 sem_init() 的第一个参数 |
当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,因此线程也就被阻塞了
1 | // 参数 sem 就是 sem_init() 的第一个参数 |
当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,但是线程不会被阻塞,直接返回错误号,因此可以在程序中添加判断分支,用于处理获取资源失败之后的情况
1 | // 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示 |
该函数的参数 abs_timeout 和 pthread_cond_timedwait 的最后一个参数是一样的,使用方法不再过多赘述。当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,线程被阻塞,当阻塞指定的时长之后,线程解除阻塞
1 | // 调用该函数给sem中的资源数+1 |
调用该函数会将 sem 中的资源数 +1,如果有线程在调用 sem_wait、sem_trywait、sem_timedwait 时因为 sem 中的资源数为 0 被阻塞了,这时这些线程会解除阻塞,获取到资源之后继续向下运行。
1 | // 查看信号量 sem 中的整形数的当前值, 这个值会被写入到sval指针对应的内存中 |
通过这个函数可以查看 sem 中现在拥有的资源个数,通过第二个参数 sval 将数据传出,也就是说第二个参数的作用和返回值是一样的