功能:完成算术运算、逻辑运算(与或非门)
如在完成1+1=2的运算时,cpu将两个数进行算术运算获取结果,回写到内存,并通过输出设备进行输出
功能:保存临时数据(不能持久化保存)
结论
(1)所有数据都是采用2进制进行存储(通过模拟电流的高低电频模拟二进制数据)
(2)运算产生的数据都是存储在内存中
扩展
(1)1个CPU同一时刻只能计算一个数据
CPU计算速度(性能)取决于制作工艺
为了提升计算速度,可以采用多个CPU进行运算
4核8C:4个物理CPU,8个逻辑CPU,可同时计算8组数据
(2)CPU运算时采取的是交替运算
若需要计算1+10^10, 1+10^11, 1+10^12时,并非是将1个计算完后才计算下一个,而是交替运算3个,令一个计算一段时间后保存状态,再去运算另一个
操作系统=操作系统内核+一堆应用
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)
操作系统内核:也是代码程序,代码作用包括(进程管理、内存管理、文件管理、驱动管理等)
一堆应用:依附于操作系统内核上完成某些功能的软件(QQ,微信等)
管理计算机软硬件资源
硬件:CPU、内存、硬盘、网卡、显示器
软件:进程资源、驱动程序
管理=描述(结构体)+组织(串联结构体)
(1)描述
用结构体描述需要管理的资源(进程)
(2)组织
用链表(双向链表)将每个结构体(进程)串联起来
自底向上看
驱动程序:其作用是专门控制硬件,本质上是一些软件
操作系统:统筹管理驱动程序与硬件(管理软硬件资源)
system call(系统调用接口):是操作系统提供的函数,程序员要使用操作系统管理的软硬件资源,就需要使用系统调用接口进行使用(不能直接使用)
用户操作接口:例如命令行
系统调用:操作系统提供的函数,称为系统调用函数
库函数:C标准库提供的函数,称为库函数。库函数实现当中调用了系统调用函数
通过系统调用函数可以管理软硬件资源,则程序员可以通过代码使用系统调用函数,从而管理软硬件资源。
由于系统调用函数非常复杂,参数过多,因此有一些能力强的程序员提供了一些库函数,库函数使用上更为便捷,在其实现中调用了系统调用函数
程序:源代码经过编译产生的可执行文件,这个文件是静态的
进程:程序运行起来的实例,是动态运行的
即管理软件资源
描述(PCB)+组织(双向链表)
描述:使用进程控制块(PCB),这个进程控制卡本质是个结构体
组织:而由于不可能只有一个进程,为了管理这些进程,采用组织的方式对一个个进程进行管理
通过双向链表将进程控制块串联,从而进行组织
进程标识符(进程号)PID
在操作系统中能够唯一标识一个进程,是一个数字
实现一个程序,无限循环打印hello world并执行
注:linux中sleep的头文件需要包含
通过以下命令可以查看进程:
ps aux
ps -ef
其中,一行代表一个进程 ,从左到右分别给出了:谁启动的、进程号PID、CPU使用率、内存使用率、虚拟内存使用率等
可以通过管道(| grep + 可执行文件名)过滤,得到想要的结果
ps -ef结果:
第2列和第3列分别为当前进程号(pid)以及父进程id(ppid)
通过以下函数可以获取进程的PID
getpid//返回short类型的整数,获取进程号
getppid//获取父进程号
可通过ps -ef查看
进程状态:就绪、运行、阻塞
(1)运行:进程占用CPU,并在CPU上运行
(2)就绪:进程具备运行条件,但CPU没有分配过来
(3)阻塞:进程因等待某件事暂时不能运行
机器当中的进程很多,CPU数量远远小于进程数量,那么进程如何获取CPU资源?
一个进程想要向后执行代码,必须拥有CPU资源
eg:
有三个进程123,链表顺序1->2->3,其中1已经开始运行,则2处于就绪状态。
若某个进程运行时需要等待标准输入中读取数据,则此时无法运行,此时为阻塞状态
CPU调度策略
机器的CPU数量少,进程多,操作系统要在调度时雨露均沾,是从就绪队列中获取进程,进行运行。因此进程谁准备好了,谁就绪了,原则上就可以调度,因此都是抢占式执行,不会互相谦让。
(1)先来先服务
(2)短作业优先
(3)长作业优先
(4)优先级优先
(5)时间片轮转(较公平):给每个进程都分配固定的使用时间,时间一到令下一个进程使用
并发
多个进程在一个CPU下采用进程切换的方式,各自独占CPU运行各自代码,交替运行,让多个进程得以推进
并行
多个进程在一个CPU下,同时运行各自的代码,称为并行
扩展
在一个多核CPU的机器中,并发和并行是混合存在的。
如有多个CPU,此时占用这些CPU的程序是并行,而其中某个CPU切换到另一个进程则是并发
细分的进程状态
(1)运行状态(R):处于运行状态的进程,可能在执行代码,可能在就绪队列
eg:写一个死循环(不包含sleep与printf)
(2)可中断睡眠状态(S):进程被阻塞,等待资源到来时唤醒,也可通过其他进程信号或式中中断环形,进入运行队列
eg:在死循环中添加sleep可看到
(3)不可中断睡眠状态(D):等待一个IO结束(输入输出结束)
(4)暂停状态(T)(ctrl+z):在linux下不要使用ctrl+z结束进程,不是结束,是暂停
(5)跟踪状态(t):调试时可见
(6)死亡状态(X):这个状态用户看不到,当PCB被内核释放时,进程被置为X,紧接着进程退出
(7)僵尸状态
程序计数器(用于恢复现场)
保存程序下一条执行的指令
CPU进行运算时,是按照汇编指令进行运算的。
若有三个进程,一个CPU,需要运算各自的汇编指令,假设第一个拿到CPU使用权,直到其时间片使用完,切换到另一个进程,这个过程叫进程切换。若下一个进程结束后又切换回第一个进程,此时应该从上一次运行结束的地方开始运行。
程序计数器记录了下一次应该从哪里开始执行
上下文信息(用于恢复现场)
保存寄存器当中的内容。在多进程系统中,操作系统调度进程,获取CPU后,恢复现场,继续执行后面的代码
CPU与内存之间其实还存在了寄存器和缓存,这是由于CPU性能较高,内存性能较低,为了匹配,增加了缓存和寄存器,用缓存将内存的内容进行读取,然后寄存器再将缓存内容读取并传递给CPU
内存指针(结构体指针)
指向程序地址空间
程序地址空间包括:栈、堆、数据段、代码段,从代码段到栈地址递增(0x00000000 -> 0xFFFFFFFF)
以32位操作系统为例,程序地址空间中的4G空间,每个字节都有一个地址编号
大端与小端
大端:低位存在高地址
小端:低位存在低地址
x86的机器都是小端机器
记账信息
包括进程使用CPU的市场、占用内存的大小
IO信息
是保存进程打开文件的信息
一个进程被创建出来,默认能够使用标准输入,标准输出,标准错误
进程存在于磁盘的文件信息,一个进程是以pid命名的文件夹
可用以下命令进入进程的文件夹中
cd /proc/进程号
如,先利用ps aux查看进程号为32717
再进入其文件夹中
需要关注的是fd文件夹
其中的0、1、2则分别对应了标准输入、标准输出、标准错误
用双向链表将一个个task_struct串联起来
fork()
需要包含#include
fork作用:令一个正在进行的进程调用该函数,可让运行的进程创建出一个子进程,两者是父子关系
fork()返回值
如果失败,返回-1
如果成功,返回两次,父进程返回大于0的数字(其实是子进程的pid),子进程返回0
如下代码
执行结果:
父进程从mian开始执行,当调用fork时,会创建一个子进程,因此多了一个task_struct结构体,然后父进程打印一个“end...”,且子进程从fork()语句后开始运行,也打印一个“end...”
为了验证上述想法,可通过getpid与getppid进行测试
结果如下:
两次打印的结果不一样,且第二次打印时,其进程是第一次打印进程的子进程
也可用命令查看,先实现以下test.c文件内容
由此会使之前的程序陷入死循环,此时可以用ps -ef查看进程
通过上图可见,6066创建了一个子进程,进程号为6067
fork的原理
原理:子进程拷贝父进程的PCB
test_struct{...}(父进程),里面有内存指针指向程序地址空间,代码段存放了父进程的代码,创建子进程时子进程以父进程的task_struct(PCB)为模板进行拷贝,包括内存指针
(1)父子进程代码共享,因为子进程拷贝了父进程的PCB,所以拥有相同的代码
(2)数据独有(栈区的数据,堆区的数据),因为各自有各自的程序地址空间
(3)父进程将子进程创建出来后,子进程是个独立的进程,被操作系统独立进行调度(调度时和父进程无关,且这两个进程仍然是抢占式执行,即子进程创建成功后,谁先被调度谁后被调度不一定)
父子进程代码相同,则子进程从哪行代码开始(重要)
(1)若从mian开始,那一定会遇到fork,则会无限调用fork
(2)从fork对应行开始,也一定会遇到fork,同样会无限调用fork
综上,子进程被创建出来后,从fork()之后运行
原因:
程序计数器中保存了程序要执行的下一条指令,因此子进程的程序计数器中拷贝的也一定是fork之后的汇编代码,因此是从fork之后开始执行
综上,从fork之后开始执行
针对fork的返回值,如何让父子进程执行不同的代码块(重要)
由于fork成功时,父子进程有两个不同的返回值,因此可以通过返回值的不同,令父子进程执行不同的代码
eg: 如下代码,运行后,只会打印第二句话(创建成功...),不打印“当前为子进程”
注:父进程的fork返回值其实是子进程的pid
eg:
由此图可见,子进程号和父进程的ret值相同,由此可见父进程fork返回值就是子进程的pid
扩展:正常在命令行中启动的进程,其父进程是谁
如图, 17901创建了17902,而17901的父进程4889其实是bash(命令行解释器)
命令行解释器本质也是各进程
在命令行中启动一个进程(运行可执行文件),原理是:命令行解释器进程创建了一个进程,让其进行程序替换,替换为了目标程序
kill命令:终止一个正在运行的进程
kill -9 [pid]:强杀一个进程
如图,父进程执行中,会陷入死循环,而子进程打印信息后可以直接执行完,子进程成僵尸进程
由图可知,21803为父进程,21804为子进程,而子进程变成了僵尸状态(defunct)的进程
此时,无法终止僵尸进程(不能让已经死的进程再死一次)
僵尸进程的产生原因:
子进程先于父进程退出,子进程在退出时,会告知父进程(信号),而父进程收到信息(信号)后忽略处理,父进程没有回收子进程的退出状态信息,导致子进程变成僵尸进程。
其中退出状态信息包括:退出码、退出信号、coredump标志位
子进程退出时告知父进程是想让父进程知晓退出状态信息
僵尸进程的危害
子进程的PCB没有被操作系统内核释放,从而导致内存泄露
如何解决僵尸进程
方法1:终止其父进程(不推荐)
如图,终止其父进程后,僵尸进程消失
方法2:重启操作系统(不推荐)
方法3:进程等待
孤儿进程产生的原因
父进程先于子进程退出,子进程变为孤儿进程
eg:
该代码令子进程处于无限循环,父进程进入后直接退出,从而模拟出父进程先于子进程退出的场景,此时子进程变为孤儿进程
执行结果:
关于命令行输入./可执行程序开始执行的理解:
之所以能够输入./可执行文件名,是因为命令行正在运行中,是个bash进程,也就是个task_struct,当输入一个./文件名后,会bash会创建一个子进程,其与bash有相同代码段,但是会通过进程程序替换的接口替换成为目标程序,若目标程序中创建子进程,则会拷贝目标程序代码
bash其实是目标程序的子进程的爷爷进程
前台进程与后台进程
在命令行中运行的进程,通常要阻塞bash进程运行(即目标程序会阻塞bash运行),即前台进程
反之,不阻塞bash运行的进程称为后台进程
前台进程使用ps aux查看时带有“+”号,后台进程没有“+”号
eg:如图所示,在父进程退出之后,使用bash命令依然能起作用,且查看进程时没有+号
对于前台程序,若想将其放在后台,可以加上&符号,若希望其回到前台,输入fg即可
孤儿进程的退出信息由谁回收
可以看到在父进程退出后,子进程的父进程id(ppid)变成1号进程,成为孤儿进程
1号进程是操作系统的init进程,操作系统很多进程都由该进程创建出来。
因此,当孤儿进程退出的时候,其退出户状态信息由1号进程进行回收
孤儿进程有无危害
孤儿进程退出时,1号进程(养父)若不回收其退出信息,会变成僵尸进程,但其实1号进程是会回收其退出状态信息的,因此孤儿进程没有任何危害
有孤儿进程,但是没有孤儿状态
什么是环境变量
环境变量是指在操作系统中用来指定操作系统运行的一些参数(操作系统通过环境变量来找到运行时的一些资源)
如:链接时,帮助链接器找到动态库;执行命令时,可以帮助用户找到命令在哪一个位置
which + 命令名称:找到命令的位置
常见的环境变量
PATH:指定可执行程序的搜索路径
HOME:指定了当前登录到linux操作系统的用户家目录
SHELL:当前的命令行解释器,默认“/bin/bash”
查看当前的环境变量
通过env命令可查看环境变量
通过echo $环境变量名 可查看环境变量值
环境变量组成包括:环境变量名称、环境变量的值,若有多个值,可通过“:”间隔
当使用命令时,需要找到这个命令在哪里,bash会查找PATH环境变量,查看其中每个路径中是否有这个命令,如果有,则执行对应路径下的命令
环境变量对应的文件
(1)系统级文件
针对各个用户都起作用,需要通过root用户修改,不推荐修改
(2)用户级别的环境变量文件
~/.bashrc:存储用户自己的环境变量文件
~/.bash_profile:也存储用户自己的环境变量文件
以上两个不管修改哪个都可以针对某个用户修改环境变量
注:对于~/.bash_profile文件,会先加载~/.bashrc,而对于~/.bashrc,会先加载系统级别的bashrc
配置环境变量
命令如下:
export 环境变量名=$环境变量名:新添加的环境变量内容
注:以下语句会直接将环境变量给改成所给的值
export 环境变量名=环境变量值
通过以上命令行修改只是“临时生效”,只在当前终端有效
若在文件中进行修改,则会永久生效,但不会立即生效,需要用source[环境变量文件名]来令其生效
新增的话:直接在文件末尾添加export 环境变量名=环境变量值
修改老的:在老的后面添加“:新增的环境变量值”
扩展:让自己的可执行程序在命令行中不加“./”
将可执行程序所在路径配置到PATH环境中即可
环境变量的组织方式
以字符指针数组的方式进行组织,最后的元素以NULL结尾(当程序拿不到环境变量时,读到NULL,就知道读取完毕了)
为何执行可执行程序需要加“./”
告诉bash要执行的可执行程序在哪
如何用代码获取环境变量
main函数参数:
(1)命令行参数
命令行参数的个数(int argc)
命令行参数的值(char *argv[])
(2)环境变量(char *env[])
注:编译时,gcc也需是根据c89标准的,可以在编译命令后加上-std=c99更改遵循的标准
获取环境变量的写法如下,遍历envp即可
命令行参数获取环境变量
如ls -a -l中的“-a”与“-l”
要用程序获取命令行参数,可用以下程序,因为argc代表命令行参数个数,argv代表命令行参数的值
如图,可以获取命令行参数
获取命令行参数后,可以通过if语句对其要显示的内容进行输出,如下程序
若输入的命令行参数为-a,则可以打印出相应内容
environ获取环境变量
extern char **environ :这是个全局的外部变量,在libc.so(C标准库)中定义,使用时,需要用extern关键字
getenv(环境变量名)获取环境变量
作用是获取一个环境变量的值(不是所有),需要传递一个环境变量名
使用时需要包含头文件#
eg:获取PATH环境变量
每个进程以一个struct结构体来描述,其中有一个内存指针,指向其程序地址空间(其实是虚拟的,叫做进程虚拟地址空间);
程序地址空间图
自底向上地址递增(0x00000000~0xFFFFFFFF)
栈向下生成(高地址到低地址),堆向上生长(低地址到高地址)
虚拟地址
eg:有如下程序,定义了一个全局变量,并创建了子进程,令父子进程打印全局变量的值与地址
结果如下:
可见这父子进程的全局变量时存储在同一个空间中的,为了验证,对程序修改为以下程序:
令子进程对全局变量进行修改,查看父进程的打印结果
然而根据结果可见,父进程的全局变量并没有被更改。
综上:
变量内容不一样,说明父子进程输出的变量不是同一个变量
但地址值相同,说明该地址不是物理地址,这种在linux中被称为虚拟地址
为什么要有虚拟地址
假设一共有8M空间,若有一个2M的文件和4M文件,此时剩余2M,但又来了一个4M的文件,且内容紧急,此时操作系统会将之前存储的2M文件置换出去,但此时剩余的4M空间并不是连续的(2M在开头,2M在末尾),因此有了虚拟地址,主要是想解决内存碎片问题,提高内存的使用率
每个进程(task_struct)中有个内存指针,指向的其实是进程虚拟地址空间
较为理论性的回答:
因为各个进程访问同一个物理地址空间,会造成不可控。在有限的内存空间中,进程不清楚哪一个内存被其他进程使用,哪一个内存时空闲的。因此,如果冒昧的使用,会导致多个进程在访问物理内存时出现混乱。综上,内存会由操作系统统一管理,但不能采用预先直接分配内存的方式给进程,因为不清楚进程会占用多少内存,使用多久,因此就给每个进程虚拟了一个4G大小的虚拟地址空间,当进程真正要保存数据或申请内存时,操作虚拟地址让操作系统给进程分配空间。
进程虚拟地址空间
操作系统为每个进程虚拟出一个4G的虚拟地址空间(32位操作系统),程序在访问内存时,使用虚拟地址进行访问,由于是虚拟地址,因此并不能直接存储数据,数据还是存储在物理内存中,在C/C++语言看到的地址全都是虚拟地址,用户看不到物理地址,由OS统一管理,OS需要将程序中的虚拟地址转化为物理地址。
每个进程都无感的使用拿到的虚拟地址,背后是操作系统进行了转换
为何32位操作系统给进程分配的是4G的虚拟地址空间
32位操作系统共有32根地址线,每个地址线可模拟0/1,因此最小为0x00000000,最大为
0xFFFFFFFF(字节),64位同理
一个子节有8比特位,一个比特位(16进制数)可用4个地址线模拟
页表
进程虚拟地址空间无法保存数据,它与物理地址空间有映射关系,该映射关系称为“页表”
一个字节有一个地址与之对应,如int占4字节,则有4个地址,但打印时只有1个地址值,这个地址是最低位的地址
通过页表,能够将最低位的地址映射到物理地址,然后通过其类型,再决定访问多少物理地址
页表如何提高空间分配效率
如图,会将虚拟地址空间分为多页,物理内存分为多块(一块为4G大小),使用页表将页与块映射起来,则将
页表如何进行映射(通过虚拟地址+页表找到物理地址)
虚拟地址=页号+页内偏移
页号=虚拟地址/页大小
业内偏移=虚拟地址%页的大小
页表中有两列(页号、页内偏移),物理地址被分为一个个小块,每个小块都也有一个页号,每个页大小为4096字节
通过计算公式得到页号和业内偏移,从而找到物理地址
如0x12121212虚拟地址,其页号为
因此,页表中会记录页号=74017, 页内偏移为530,之后会在物理地址中找到页号为74017的块,在其4096大小的空间中找530地址,就是最终的物理地址
页表的工作方式叫做“分页式”,此外,还有“分段式”与“段页式”。
段表
由于维护页工作量较大,因此有了段表
段表将物理内存划分的空间更大,容易造成空间浪费,因此又有了“段页式”
分段式
虚拟地址=段号+段内偏移
段号:段的起始地址
段页式
虚拟地址=段号+页号+页内偏移
段号:页表的起始位置
页号:块号
(1)每个进程都有独立的虚拟地址空间,进程访问的虚拟地址并不是真正的物理地址
(2)虚拟地址可通过每个进程上的页表与物理地址进行映射,获取真正的物理地址
(3)若虚拟地址对应的物理地址不在物理内存中,则会产生缺页终端,真正分配物理地址,同时更新进程的页表。如果此时的物理内存已经耗尽,则根据内存替换算法淘汰部分页面至物理磁盘中
进程的优先级是由操作系统来决定的
可以查看ps -l命令时输出的内容:
UID:执行者身份
PID:进程号
PPID:父进程号
PRI:代表进程被执行的优先级
NI:代表进程的nice值,表示进程可被执行的优先级的修正值
当NI值为负时,优先级值会变小,则其优先级变高,越快被执行
nice取值范围:[-20, 19],共40个级别
修改优先级(即修改nice值)
有如下程序
使用top命令查看优先级,若没有则可能是该程序CPU使用了过低,所以没显示
按r,输入目标程序的PID,然后会提示修改优先级,输入符合要求的ni值即可
让正在运行的进程创建一个子进程,新进程为子进程
pid_t fork(void)
父子进程都是独立的进程,fork分别在父子进程中返回。
返回值:若创建成功,则子进程返回值为0,父进程返回值是子进程的pid;创建失败则给父进程返回-1
目标:创建子进程
步骤:
(1)分配新的内存块和内核数据结构给子进程
(2)将父进程部分数据结构内容拷贝到子进程
(3)将子进程添加到系统进程列表中(将子进程放入双向链表中)
(4)fork返回,开始调度器(操作系统)调度
调度策略
先来先服务
短作业有限
优先级优先
时间片轮转
内核空间
linux操作系统与驱动程序运行在内核空间,即系统调用的函数都在内核空间运行,因为是系统提供的函数
用户空间
应用程序都运行在用户空间,即自己写的代码在用户空间运行,当所写的代码调用了系统调用函数,会切换到内核空间执行,执行结束后会返回到用户空间继续执行用户代码
eg:
在调用fork函数时,发生在用户空间,但fork执行时在内核空间
每个进程都有一个页表结构
fork创建的子进程,也会拷贝父进程的页表关系
写时拷贝:父进程创建子进程,子进程拷贝父进程PCB,包括页表。最初,同一个变量的虚拟地址与物理地址的映射关系一样,即操作系统并未给子进程中的变量在物理内存中分配空间进行存储,子进程的变量还是原来父进程的物理地址中的内容,而当发生改变的时候,则会以写时拷贝的方式拷贝一份,此时父子进程通过各自的页表,指向不同的物理地址,当不改变时,父子进程共享同一份数据。
之前子进程修改a=20,就是发生了改变,从而发生写时拷贝,从而导致同一个地址出现两种值的现象
(1)父子进程是独立运行的,互不干扰。各自有各自的进程虚拟地址空间与页表,数据不会窜
(2)父子进程是抢占式运行。谁先谁后是操作系统调度决定的
(3)子进程从fork()之后开始运行(程序计数器+上下文指针)
(4)父子进程代码共享,数据独有
守护进程
提高程序“高可用”的一种手段
父进程创建子进程,令子进程执行真正的业务,父进程负责守护子进程。当子进程在执行业务时意外“挂掉”(通过进程间通信知晓其挂掉),父进程负责重新启动子进程(进程程序替换),让子进程继续提供服务
(1)代码运行完毕,结果正确
(2)代码运行完毕,结果不正确
(3)代码异常终止
前两个属于正常终止,第3个属于异常终止
命令行中,可以通过echo $?来查看进程退出码
这个0就是main函数中最后的return 0
(1)从mian函数中的return返回
(2)调用exit,需要包含#
因为exit执行完就退出了,所以不打印hello与2
(3)调用_exit
(1)解引用空指针/野指针(指向非法地址的指针, 也叫垂悬指针)
(2)double free
(3)内存访问越界
exit会多做两件事
(1)执行用户自定义的清理函数
(2)刷新缓冲区
如何自定义清理函数
atexit(void(*function)(void)):参数是个函数指针,指向函数的地址,atexit注册函数指针保存的函数地址到内核当中,这个过程中没有对指向的函数进行调用,当程序结束时,会自动调用注册的函数
回调
程序达到某种场景会调用的函数称为“回调函数”
操作系统在打印内容时(printf),要打印的内容会先放入缓冲区,然后需要用户自己对缓冲区进行刷新,才能输出到屏幕上,否则不显示
逻辑上,应该先打印aaaa然后5秒后程序退出,但实际上直到程序退出时才会打印aaaa,这就是因为没有对缓冲区进行刷新导致
刷新方式如下:
(1)\n就有刷新缓冲区的作用
(2)从main函数的return返回有刷新缓冲区的作用
(3)exit有刷新缓冲区的作用,但_exit没有
(4)fflush(stdout)有刷新缓冲区的作用,其中stdout是标准输出
(1)全缓冲:当缓冲区写满,才进行输入输出
(2)行缓冲:当在输入输出中遇到换行符,标准IO库执行IO操作
(3)不缓冲:标准IO库不对字符进行缓冲存储
模拟一个僵尸进程(子进程相对父进程先挂掉,且父进程没有回收子进程的退出状态信息),代码如下:
为了防止僵尸进程,需要令父进程进行进程等待,等待子进程退出之后,回收子进程的退出状态信息
pid_t wait(int *status)
作用:等待进程改变状态
头文件:#
参数:输出型参数,获取子进程退出状态信息,不关心可设置为NULL,参数值是wait函数进行赋值,用户根据这个参数获取得到退出进程的退出状态信息
返回值:成功则返回等待进程的pid,否则返回-1
wait函数是个阻塞调用函数(死等),当调用wait时,会陷入wait函数内部,直到等到了子进程才会返回
由图可知,没有了僵尸进程,只剩了父进程
如何证明wait是个阻塞调用函数
可以给子进程加入一个sleep,则父进程肯定会先调用wait,若其不是阻塞调用,父进程会直接结束,若是,则会等到子进程苏醒并退出后才会结束。
pstack pid命令:查看堆栈调用情况
status参数
其所指向的整型空间4字节,只看前两个字节,会被划分为3个部分(第一个字节的最高位的比特位)
第二个字节存储退出码,第一个字节的最高为存储coredump标志位,其余存储退出信号
【第二个字节(8比特位)】【coredump标志位(1个比特位)+退出信号(7个比特位)】
那么如何通过进程退出状态信息判断进程是正常还是异常退出的
(1)看退出信号是否有值
正常终止:退出信号=0,只有正常退出才会有退出码
异常终止:退出信号>0
(2)看coredump标志位
coredump标志位:标志着是否产生核心转储文件
正常:0
异常:1
如何获取退出信息
对于退出信号,可令status直接^0x7f
对于coredump标志位,由于其在第一个字节的最高位,因此令status直接右移7位即可
用解引用空指针模拟子进程异常退出,查看结果
通过退出信号可知为异常退出,这里coredump为0是因为core file size设置问题,如何设置可见之前Linux命令与工具章节的笔记内容(4.4.3小节中)
该函数可以有非阻塞属性(需要设置)
函数原型
pid_t waitpid(pid_t pid, int *status, int options)
pid:-1代表等待任意子进程,与wait等效;pid>0,等待进程ID与pid相等的进程
status:退出状态信息,等同于wait函数的参数
options:WNOHANG,可以设置为非阻塞状态,若pid指定的子进程没有结束,则waitpid返回0,不予等待,若正常结束,则返回该子进程的ID
返回值
当正常返回时waitpid返回收集到的子进程的ID
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0
如果调用中出错,则返回-1,此时errno会被设置为相应的值以指示错误所在
非阻塞调用一定要搭配循环使用
验证watipid阻塞
前十秒
通过pstack命令可知,waitpid的确处于阻塞状态
十秒后:
可见子进程没有变为僵尸进程,由此说明waitpid的确可以起作用
验证waitpid非阻塞
当调用waitpid时,子进程处于睡眠,因此若其为非阻塞,则父进程紧接着会陷入循环中,不会回收子进程的退出状态信息,因此10秒后可见子进程变为僵尸进程
使用非阻塞调用等待子进程
每次循环,先判断子进程是否退出,是则父进程会回收其状态信息然后退出,否则继续等待
可见10s后等待到了子进程,父进程也退出了
输入命令到命令行回车,bash会创建子进程,然后让子进程替换为自己的进程执行
为什么需要进程程序替换?
因为父进程创建的子进程与父进程有相同代码,若想令子进程执行不同程序,需要让子进程调用进程程序替换的接口,从而让子进程执行不同的代码
替换进程的代码段与数据段,更新堆栈
(1)execl函数
int execl(const char * path, const char *arg, ...)
path:带路径的可执行程序,从而替换成这个程序
arg:给程序传递命令行参数,第一个命令行参数是程序本身,最后一个参数以NULL结尾(与main中的环境变量参数类似char *envp[])
注:该函数参数个数不定,因为后面都是命令行参数
返回值
函数若调用成功则加载新的程序,从启动代码开始执行,不再返回,失败则返回-1
由图可知,当执行execl之后,原先程序就被替换为目标程序,原先程序中的剩余部分代码不执行(图中没有打印end...)
令execl执行自己写的代码
目标程序:
原程序:
执行结果:
可见源程序被替换为了目标程序
(2)execlp函数
int execlp(const char *file, const char *arg, ...)
file:可执行程序,可以不用带路径,也可带有路径
arg:命令行参数,第一个需要是可执行程序本身,若需传递多个,用“,”分隔,NULL结尾
不带路径如下:
注:不带路径前提是“不加路径程序也能找到这个文件”
不带路径则会从环境变量中找这个文件
(3)execle函数
int execle(const char path, const char *arg,...,char *const envp[])
path:可执行程序的(需要路径)
arg:命令行参数,第一个必须是程序本身,最后一个为NULL
envp:环境变量,调用该函数时,需要自己组织环境变量传递给该函数
(4)execv函数
int execv(const char *path, char *const argv[])
path:带路径的可执行程序
argv:传递给可执行程序的命令行参数,以指针数组的形式传递,第一个是程序本身,末尾为NULL
(5)evecvp函数
int execvp(const char *file, char *const argv[])
file:可执行程序,可不带路径,也可带用路径
argv:以指针数组方式存储命令行参数,第一个必须是可执行程序本身
(6)execve函数
execve(const char *path, char * const argv[], char * const envp[])
path:带路径的可执行程序
argv:命令行参数
envp:环境变量,需要自己组织
总结
(1)函数名带有p:可以使用环境变量PATH,可以不用带有路径,但如果环境变量中没有目标程序,则替换会失败
(2)函数名带有l:传递给可执行程序的参数是以可变参数列表的方式进行传递,命令行参数的第一个必须是可执行程序自己本身,如果传递多个,需要用“,”间隔,以NULL结尾
(3)函数名带有“e”:需要自己组织环境变量传递给函数
(4)函数名带有“v”:以数组指针方式传递命令行参数
只有execve是操作系统提供的(系统调用函数),其余都是库函数
补充
const char * argv[]:const修饰的是指向的字符串,代表指针指向的字符串不能被修改
char const * argv[]:同上
char *const argv[]:const修饰的是存储的地址,代表地址不能被修改
功能:打开文件
FILE* fopen(const char *path, const char *mode)
path:打开的文件(带路径)
mode:何种方式打开
打开文件的方式
r:只读模式,文件流指向文件头部
r+:读写,文件流指向文件头部(写入的内容会从头开始覆盖)
w:只写,如果文件存在,则清空文件开始写,若文件不存在则创建文件,文件流指向文件头部
w+:可读写,文件不存在则创建文件,文件存在则清空后从头开始写
a:追加写,从文件末尾开始写,文件不存在则创建文件,从文件末尾开始写
a+:可读也可追加写,文件不存在则创建文件,从文件末尾开始写
返回值
成功:返回文件流指针
失败:返回NULL
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE* stream)
ptr:想向文件中写什么内容
size:定义向文件中写的时候写的时候,一块是多大,单位为字节
nmemb:写多少块
FILE:文件流指针,向哪个文件中写
返回值:成功写入文件的块的个数(注:返回块的个数,不是字节)
eg:
由此也可见定义块大小以及块个数很麻烦,因此可以将块看做字,则可将参数看做(写入的内容,字大小,字个数,目标文件指针)
如下:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream)
ptr:从文件当中读到的内容保存至ptr指向的内存空间中,空间需要提前准备
size:定义块大小(字节)
nmemb:读多少块
stream:文件流指针
返回值:成功读入的块的个数
eg:准备一个1.txt文件,内容如下
写程序读取其中内容
为何fread中,需要sizeof(s)-1?
因为希望预留'\0'的位置,假设文件内容为abcdef(str),若最开始的空间(buf)为3字节,则调用fread(buf, 1, sizeof(buf), f)时,会将所有空间都占用,没有空间放入'\0'
打开文件后写入,然后读取的问题(引出fseek函数)
若有如下程序,对文件写入然后读取
可见写入成功但读取失败
因为,写入完毕后文件流是在world的后面,而读取时,会从文件流开始读取,因此没有读取到内容。要解决就需要改变文件流的位置,可以用fseek函数
int fseek(FILE*stream, long offset, int whence)
用于移动文件流指针的位置
stream:文件流指针
offset:偏移量
whence:将文件流指针偏移到什么位置(SEEK_SET:头部, SEEK_CUR:当前文件流指针位置,SEEK_END:文件末尾)
返回值:成功0,失败-1
eg :
对于上一节的例子,可以添加语句成如下程序
结果如下:
功能:关闭文件流指针
int fclose(FILE * fp)
打开文件后一定要关闭,否则会造成文件句柄泄露
上一节中介绍的C语言中的文件接口属于库函数,现在介绍系统调用函数
int open(const char * pathname, int flags, mode_t mode)
pathname:目标文件路径
flags:打开文件时的打开方式,可以传递多个参数选项
要包含头文件
以下三个必选一个(只能选一个)
O_RDONLY:只读打开
O_WRONLY:只写打开
O_RDWR:读,写打开
以下为可选,可以和上面的任意一个用“|”连接,如O_RDONLY | O_CREAT:代表只读且文件不存在则创建
O_CREAT:文件不存在则创建
O_APPEND:追加写
model:当创建一个文件时,指定文件的权限(8进制数字),如0664
返回值:成功则返回新打开的文件描述符,失败则返回-1
eg:
执行结果:
扩展
创建一个进程,在进程代码中可进行打印、读入内容,这是因为在进程创建过程中,分别创建了标准输入(stdin)、标准输出(stdout)、标准错误(stderror)
stdin标准输入:scanf,C库中就是scanf,而操作系统中对应的就是0号文件(进程概念章节有提及),以此类推
stdout标准输出:printf
stderror标准错误:perror
功能:向文件中写入内容
ssize_t write(int fd, const void*buf, size_t count)
fd:文件描述符
buf:将buf指向的内容写入文件中
count:期望写入多少字节
返回值:返回写入的字节数量
ssize_t read(int fd, void *buf, size_t count)
fd:文件描述符
buf:从文件中读取的内容会写入buf,需要提前自定义好空间大小
count:期望读取到的字节数量
off_t read(int fd, off_t offset, int whence)
fd:文件描述符
offset:偏移量,单位字节
whence:偏移位置,SEEK_SET头部,SEEK_CUR:当前文件流指针的位置,SEEK_END末尾
返回值:成功返回偏移的位置(字节),失败返回-1
int close(int fd)
关闭文件
文件描述符是个整数(值较小)
由此可见文件描述符最多为100000
打开失败是因为一个进程打开的文件太多了
一个进程打开文件描述符到达到达进程限制的上限了
可以用ulimit -a命令查看打开文件描述符的上限
这是操作系统的软限制,可通过ulimit -n [num]来修改
硬限制:就是操作系统的资源
打开文件描述符需要耗费内存资源,若机器没有内存资源,及时软限制设置的再大,也没有用
文件描述符分配规则
文件被创建出来时,默认打开三个文件描述符(标准输入、输出、错误)
分配规则:最小未使用原则
eg:
如下图所示程序,除了默认的三个文件描述符外,又打开了1.txt两次,则fd应该是3与、4
通过cd /proc/进程号/fd命令,查看其中的文件有5个,34指向了1.txt文件
若一开始关闭0和2,如下:
则新打开的两个会分别用0和2作为文件描述符,这就是最小未使用原则
从内核角度理解文件描述符
(1)从task_struct角度理解文件描述符在内核中是什么
文件是进程中打开的,因此文件描述符脱离不了进程范畴
进程描述打开文件的信息的结构体指针(struct files_struct *files)指向一个结构体(sturct files_struct),其内部有有个数组,该数组中的元素是个结构体指针(struct file*),这个结构体用于描述文件信息(文件名称、大小、权限、所有者、属性、文件在磁盘中的存储位置...),而文件描述符就是files_struct中数组的下标
如图:若另外打开后,则Files struct中的array 数组会增加一个file*指向新的file结构体
文件描述符传递给read或write函数,由此进程通过其结构体指找到Files结构体中的array,由进程描述符找到相应的文件结构体指针file,进而找到文件结构体file,然后就可以得到文件在磁盘中的位(file结构体中存储了文件在磁盘中的存储位置)
文件描述符与文件流指针(FILE*)的区别
文件流指针所对应的结构体(struct _IO_FILE),该结构体属于C库的内容,并不是操作系统内核的东西,包括:读缓冲区、写缓冲区、int_fileno(保存文件描述符的数值),之前进程控制中的冲刷缓冲区就是读写缓冲区。
通过fopen打开文件,返回一个文件流指针,其指向的结构体中的fileno保存了文件描述符
>:清空重定向:将原来的内容清空,再重定向上新的内容
>>:追加重定向:在原来内容后面添加新的内容
将文件描述符对应的文件可以重定向为其他文件(将文件描述符对应的结构体指针的指向进行改变,指向另一个文件)
重定向就是讲struct file*结构体指针的指向改变成为另一个strucy file结构体
int dup2(int oldfd, int newfd)
作用:将newfd的值重定向为oldfd,即newfd拷贝oldfd
参数:oldfd/newfd均是文件描述符
成功则关闭newfd,并让newfd指向oldfd对应的struct file结构体
失败,若oldfd是个非法/无效的文件描述符,则失败,newfd不变;若oldfd和newfd值相同,则什么也不做
代码验证--将标准输出重定向为1.txt文件
可见,运行test文件后,并没有输出hello world,而是将其重定向到了1.txt文件中
静态库与动态库本质是文件(库文件),保存程序代码
作用:提供给第三方使用,保护商业秘密,可以将代码分模块出来,哪个模块有问题,就编译哪个模块
什么是库:静态库与动态库都是程序代码的集合。一般是为了方便将程序提供给第三方使用(将程序编译成库文件提供给第三方用户使用),好处是不会泄露公司的源码,调用者不必关心内部的实现,只需要关注如何使用(调用)即可
特征
在windows系统中,后缀为.dll的文件
在Linux系统中,前缀为Lib,后缀为.so的文件(libxxx.so)
动态库的生成及使用
使用gcc/g++编译器,增加两个命令行参数
-fPIC
-shared
生成动态库的代码中不需要包含main函数(程序入口函数)
eg:
有hello.h文件,其中声明一个函数printhello(),然后在hello.c中对其实现,然后通过hello.c生成一个动态库libhello.so,其中就包含了printhello函数的实现代码(二进制)
现在在main.c中,如果想调用printhello(),则可以在编译时链接到libhello.so这个库,从而产生相应的可执行程序
如下:创建.h、.c文件,.h给调用者使用,.c用于生成动态库
.h文件
动态库来源程序:
生成动态库:
调用者文件.c文件:
编译链接到动态库
注:这里gcc中最后的ltest2意思是l+动态库名(去掉前缀lib与后缀.so)
补充
通过ldd + 可执行程序可以查看其依赖哪些文件
如何让可执行程序在运行阶段找到动态库
若将动态库转移到上级目录,然后运行test
那么如何令程序能够找到动态库?有如下方法
(1)将动态库放到可执行程序的路径下(不推荐)
(2)配置LD_LIBRARY_PATH(动态库的环境变量)
将动态库所在的路径配置到环境变量中此时就可运行test了
(3)放到系统库的路径下(不推荐)
特征
windows系统中,没有前缀,后缀为.lib的文件
linux系统中,前缀为lib,后缀为.a的文件
生成
步骤1:使用gcc/g++将源码编译为目标程序(.o)
步骤2:使用ar -rc命令编译目标成为静态库
注:如果直接用源码进行编译是不行的
eg:
步骤1:
步骤2:
注:
使用ldd命令查看程序依赖的文件时,看不到静态库,这是因为在编译时就已经将静态库编译到可执行程序中了
目标文件的快捷方式
生成:ln -s 源文件 软连接文件
注:
(1)修改软连接文件,源文件也会被改
(2)源文件若删除,软连接文件还在,修改软连接文件会重新建立源文件,重新建立链接关系,一定要在删除源文件的时候将软连接文件也删除(若删除源文件,但是又修改了软连接文件,则会重新创建出源文件)
eg:
在ln_test文件夹下有1.txt文件,写入helloworld内容,对其创建快捷方式2txt,修改2txt
由此可见2txt文件类型为l,指向了目标文件
修改2txt
发现源文件也修改了
目标文件的替身
对软连接文件,用ll -i查看inode可以发现源文件与软连接文件的inode不同
硬链接文件与源文件有相同的inode,且硬链接文件是个普通文件
命令:ln 目标文件 硬链接文件
eg:由下图可见硬链接文件与源文件的inode相同
文件在磁盘中是如何进行存储的(ext2)
一个磁盘可以被划分成为多个分区,每个分区都可以有自己的文件系统,磁盘分区被划分为一个个block,一个block大小是由格式化时确定的,且不可更改。
超级块(super block)
存放文件系统本身的结构信息。记录的信息包括:block与inode的总量,未使用的block与inode的数量,一个block和inode的大小等等
block是存储文件内容的区域
inode是存储文件信息的节点
Group Descriptor Table
GDT,块组描述符,描述块组属性信息
Data blocks
存储文件位置的区域
存储文件的规则不是将文件连续存储,而是离散存储,避免了磁盘碎片的大量产生
Block Bitmap
用位图描述Data blocks区域中block的状态时空闲还是被使用
inode Table
描述了文件的存储信息(存储在哪些block块中),方便读取
inode Bitmap
描述inode结点的使用情况
创建一个新文件的4个操作
(1)存储属性:内核找到一个空闲i节点。内核将文件信息记录其中
(2)存储数据:文件需要存储在三个磁盘块,如图中找到3个空闲块,300、500、800
(3)记录分配情况:文件内容按顺序300、500、800存放,内核在inode上的磁盘分布区记录了上述块列表(将文件信息存储到inode结点中)
(4)添加文件名到目录:将文件名称+inode作为目录的目录项进行保存
综合来说:存储时先找到空闲inode结点,将数据离散存储,再将离散存储的数据区信息存储到inode结点中,再将文件名称与inode结点号组合作为目录的目录项进行保存。
如果要找某个文件:根据文件名->idnode结点->inode内容->离散存储的数据内容->组合
ls -i -l可以查看文件的inode结点号
进程概念中提及到:一个进程的PCB中有一个内存指针,指向进程的虚拟地址空间,其通过页表映射到物理地址。
从进程角度看,每个进程认为自己拥有4G空间,至于物理内存中如何存储,页表如何映射,进程不清楚,这使得进程具有独立性
进程独立性的好坏
好处:让每个进程运行时独立运行,数据不会窜
坏处:当两个进程需要数据交换,由于进程独立运行,导致数据交换不方便
综上,进程间通信是为了让进程与进程之间交换数据的
ps aux | grep xxx原理
将ps进程运行的结果传递给grep进程(二者之间进行了数据交换),令grep通过“xxx”关键字进行过滤
进程间通信的方式包括管道、共享内存、消息队列、网络
(1)管道符号
管道符号:“|”
如有下列命令
ps aux | grep test
其中,ps aux列举了当前linux操作系统中的进程信息
“|”为管道符号
grep:过滤存在“test”字符串的项
综上,ps aux的返回结果通过管道传输给grep进程,grep在ps aux的结果中过滤存在"test"的字符串
(2)管道的本质
在内核区域中管道是个缓冲区(即内核空间,见2.1.3),供进程进行读写,交换数据
ps aux最终的执行结果会交给管道的缓冲区中,grep会从管道中将ps aux结果读取,将其作为grep的输入,进而搜索“test”
(3)管道的接口
int pipe(int pipefd[2])
需要包含unistd.h
参数:
pipefd:是个出参(不用自己给出具体值),数组,有两个元素,0位置为管道的读端,1号位置为管道的写端.代表数组元素保存的值从哪里来
返回值:
创建成功为0, 失败为-1
调用pipe后,会在内核创建一个缓冲区,用户需要向其中写或读数据,因此该函数提供了读端和写端,如图,fd[0]为读端,fd[1]为写端
注:在调用pipe之前,用户不知道pipefd中的元素值,因此这个pipefd参数是个出参,其中的值由pipe函数进行填充
这个pipefd数组元素保存的内容是文件描述符,用户通过操作匿名管道两端的文件描述符来对相应文件进行读写
(4)从PCB角度理解管道(重要)
一定是某个进程(task_struct)调用pipe函数创建了一个管道
如图所示,若进程代码段中调用了pipe函数,则会在内核中创建匿名管道的内核缓冲区,其有读端fd[0]和写端fd[1],分别对应了进程的内存指针指向的结构体中fd_array中的元素file*的下标(3、4),也就是文件描述符,同时,文件描述符对应位置的结构体指针file*会指向一个结构体file,其中会额外描述内核匿名管道所对应的缓冲区空间信息
eg:验证pipe函数(出参,fd[0]、fd[1]是文件描述符,通过fd是否能够进行数据交换);结合父子进程,令父子进程通信
验证出参:
由此可见,pipe的参数为出参
验证fd[0],fd[1]是文件描述符:
由此可见fd[[0]与fd[1]确实是文件描述符
验证通过fd进行数据交换(write/read)
结合父子进程,令父子进程进行通信
父子进程要通过匿名管道通信,核心在于父子进程都要能读写管道(父子进程要有管道的读写两端的文件描述符)
而创建子进程时,是拷贝父进程的PCB的,则涉及问题:先创建管道还是先创建子进程:
若先创建子进程,则子进程中没有文件描述符,不知道父进程管道的位置,因此父子进程要想通信,需要先在父进程创建管道,然后再创建子进程
由图可见,子进程成功读取了父进程写入的hello数据
父进程创建子进程,父子抢占式执行,则有两种情况,为何如何运行,输出结果都是一致的
若先父后子,则先写入后读取,合理。
若先子后符,则先读取后写入,应该读取不出来,但实际上是可以读取出来的,原因是当管道没有数据时,子进程调用的read函数从管道中读的时候,会阻塞,直到管道中有内容,read函数读回来,read才返回。
对上图程序,令父进程先睡眠10s再写入,令子进程先进行读取,查看其是否阻塞,结果如下:
前10s:
10s后:
注:管道写满后,如果调用write向其中写入数据会阻塞
(6)管道的特性
半双工:数据只能从写端流向读端
匿名管道没有标识符,只能具有亲缘性关系的进程(且要有读写两端的文件描述符)进行进程间通信
管道的生命周期跟随进程,进程退出,管道在内核中就销毁了
管道的大小为64k(不读数据,持续向管道写入数量,计算写入的字节数据)
管道提供字节流服务:
先后两次写入管道的数据之间没有间隔,如下程序,缓冲区中数据是“abc123”,没有间隔
write(fd[1],"abc", 3);
write(fd[1],"123",3);
数据被读取,并非拷贝走(即读取后,缓冲区中的数据就没有了,读取的时候可以按任意大小读取,即想读几个字都行)
pipe size
pipe size大小4096字节(512*8),并非管道大小
当写入/读取的字节数量< pipe size,则管道保证原子性(要么一个都没读/写,要么全都读/写完)
查看pipe size方法:ulimit -a命令
pipe size可修改:ulimit -p [num]
什么是原子性?不可拆分,在计算机中表示没有中间状态(非黑即白),例如对于某个进程在写入管道时,要么什么都没写,要么全部写完了,不会出现写了一半不写了。
一组操作,要么全部执行 ,要么一个都不执行
(7)设置非阻塞属性及验证非阻塞属性
阻塞属性:读写两端的文件描述符初始的属性为阻塞属性
当write一直写且不读,则write会阻塞
当read一直读,当管道内部读完,read会阻塞,直到管道内有内容,才会读取
非阻塞属性:针对管道的读写两端的文件描述符而言
设置非阻塞属性(O_NONBLOCK):
int fcntl(int fd, int cmd, ...)
fd:待操作的文件描述符(管道的读写两端)
cmd:告诉fcntl函数要进行什么操作,F_CETFL获取文件描述符的属性信息,F_SETFL设置文件描述符的属性信息,设置新的属性放到可变参数列表中(将第三个参数传入的属性信息,直接替换给文件描述符)
如下:fcntl读取到原来读端的属性信息,然后在此基础上用“|”添加了非阻塞属性“0_NONBLOCK”
返回值:设置成功返回0,否则返回-1
验证非阻塞:如下,管道中为空,理论上此时读取会阻塞,如果阻塞,程序不会打印“bbb”
若删除fcntl函数,则执行结果如下:
用fcntl获取读端和写端的属性如下:读端为0,写端为1
设置了非阻塞属性后,读端的属性为2048,
原因:fd[0]文件描述符具备的属性信息为O_RDONLY(内核中定义了其值为0),因此最开始其属性值为0,而fd[1]文件描述符的属性信息为O_WRONLY(内核中定义了其值为1),所以一开始为1。为何设置非阻塞属性后变为2048,内核中,#define O_NONBLOCK=2048,则一开始的0和2048通过“|”连接,结果为2048.
为何文件描述符的属性信息要用“|”设置?
原因:文件描述符的属性信息,在操作系统内核中用比特位表示,O_NONBLOCK=2048是认为第10个比特位为1就认为具有非阻塞属性,因此如果要添加某个属性,就用“|”连接该属性,将相应的比特位置为1。因此,用“|”连接,就是讲某个属性对应的比特位修改成1,。
读设置非阻塞后的情况:
写不关闭(进程可以正常操作fd[1],可以正常操作写端),一直读,读端调用read后,返回值为-1(告知资源不可用),errno设置为EAGAIN,
写关闭(进程调用close函数将fd[1]文件描述符关闭),一直读,读端调用read后,返回0,表示什么也没找到
写设置非阻塞后的情况:
读不关闭,一直写,写满后,调用write会返回-1(告知资源不可用)
读关闭,一直写,写满后,调用write会发生崩溃
eg:创建管道和子进程,子进程写,父进程读,将各自不关心的fd关闭
运行后,查看父子进程的文件描述符,可见父进程剩下了读端(3),子进程剩下写端(4,3被关闭)
eg:读设置非阻塞,写不关闭
由此可见,读端未被不阻塞,但子进程变为了孤儿进程,errno含义为资源不可用(没有读到信息)
eg:读端非阻塞,写端关闭
令子进程先运行,令其写端关闭,然后父进程去非阻塞读取,发现其read返回值为0
(8)扩展
系统接口中,文件打开方式的宏,在,在内核中的使用方式为位图,如O_RDONLY、O_CREATE等
操作系统中大量的使用位图。因为位操作块,且节省空间
文件描述符其实是个值,其属性指的是其对应的struct file*指向的file的属性(见3.3节的图)
不允许将fd[0]设置可写,获奖fd[1]设置可读属性(但是代码可运行,不会报错)
创建命名管道
(1)mkfifo命令
mkfifo 文件名
可见该文件类型为“p”,叫做“管道文件”
当两个进程都打开管道文件时,都可以对其进行读写操作,但读写时数据都是存储在内核的缓冲区中,即读取时从缓冲区读,写的时候写入到缓冲区中。
管道文件的作用是为了让不同的进程能够找到这个缓冲区
(2)函数创建
#include
int mkfifo(const char * pathname, mode_t mode)
pathname:要创建的命名管道文件(带路径)
mode_t:指定管道文件的权限(0664)
返回值:成功0,失败-1
eg:一个文件写,一个文件读
写文件
读文件
当ctrl读文件时,写文件也会结束,这是因为其结束时,此时写端写发现没有人其他程序打开该文件,发送信号结束进程。
当先运行写端时,若发现没有读端读取,则open会阻塞
当先运行读端,则发现没有写端程序打开命名管道,则open会阻塞
特性
支持不同的进程进程间通信,不依赖亲缘性,因为不同的进程可以通过命名管道文件,找到命名管道(操作系统内核的缓冲区)
共享内存是最快的进程间通信方式,很多追求效率的程序之间进行通信时,会选择共享内存
如:守护进程与被守护进程
在物理内存中开辟一段空间;不同的进程通过页表将物理内存空间映射到自己的进程虚拟地址空间中,不同的进程通过操作自己进程虚拟地址空间中的虚拟地址,来操作共享内存。
创建或获取共享内存接口
int shmget(key_t key, size_t size, int shmflg)
key:共享内存标识符(共享内存的名字)
size:共享内存的大小
shmflg:获取/创建共享内存时,传递的属性
IPC_CREAT:若创建的共享内存不存在,则创建
IPC_EXCL | IPC_CREAT:若获取的共享内存存在,则函数报错,若不存在,则创建,这个组合本质是想获取重新创建的共享内存
属性按位或上权限,权限也是8进制数字(如0664)
返回值:成功返回共享内存的操作句柄,失败返回-1
附加
共享内存附加到想要通信的进程(令进程的虚拟地址和物理内存通过页表建立映射关系)
void * shmat(int shmid, const void *shmaddr, int shmflg)
shmid:共享内存操作句柄
shmaddr:将共享内存附加到共享区中的第一个地址上,通常让操作系统自己分配,传递NULL
注:在进程的虚拟地址空间中,栈与堆之间的空间中,有一个共享区(如下图),共享内存附加到这个区域,线程的内容也存储在共享区。
综上,shmaddr参数是告诉操作系统要把物理内存中的某个地址通过页表映射为共享区中的哪个地址,但通常我们不知道具体是那个地址,所以一般让操作系统自己去映射即可,返回值会告知附加在了哪个虚拟地址上
shmflg:以什么权限将共享内存附加到进程中(这个权限是针对进程的,不是修改共享内存原本的属性)
SHM_RDONLY:只读
0:可读可写
返回值:成功返回附加的虚拟地址,失败返回-1
分离
与附加相反,删除进程虚拟地址和物理内存通过页表建立的映射关系
int shmdt(const void *shmaddr)
shmaddr:shamat的返回值,就是映射到物理内存中的第一个虚拟地址
返回值:成功0失败-1
操作
更改共享内存大小、权限的
int shmctl(int shmid, int cmd, struct shmid_ds *buf)
shmid:共享内存的操作句柄
cmd:告诉shmctl函数需要完成什么功能
IPC_SET:设置共享内存属性信息
IPC_STAT:获取共享内存属性信息
IPC_RMID:删除共享内存
buf:共享内存数据结构buf,当需要获取共享内存属性信息时buf是出参,内容由函数填充,当要设置共享内存属性信息时,buf是输入型参数,内容由程序员组织,传递给函数,由它修改共享内存的属性
eg:分别创建写端文件和读端文件,如下为写端,创建共享内存名为0x123456(也可以传递10进制整数),1024为共享内存大小,然后将其附加到进程中,进程权限0(可读可写),之后向内存中写入内容,方法是用strcpy将内容拷贝进去,最后sleep一段时间方便读文件读取,然后分离
读端:注意读取内容时,因为写入是从ptr开始写,所以可以从ptr开始读
(1)声明周期跟随操作系统
共享内存是覆盖写的方式(每次写的时候会清空之前的内容,因为每次都拿到的是首地址),读的的时候是访问地址,并未将数据读走(与管道不同,读取管道内容时会将管道内容读走)
(2)共享内存的删除特性
ipcs与picrm -m [shimid]命令
前者查看共享内存,后者用于删除共享内存
一旦共享内存删除,共享内存在物理内存中的空间会被销毁
若删除共享内存时,共享内存附加的进程数量为0,则内核中描述该共享内存的结构体也被释放
若删除共享内存时,共享内存附加进程数量不为0,则会将该共享内存的key变为0x00000000.表示当前共享内存不能被其他进程所附加,共享内存的状态会被设置为destory,附加的进程一旦全部退出,该共享内存所在内核的结构体也会被释放掉。
只要满足先进先出特性的数据结构,都可以称为队列
(1)msgqueue采用链表实现消息队列,该链表通过系统内核维护
有两个进程,用消息队列进行通信,则进程a作为写端向链表中插入,进程b作为读端会从链表中拿元素
(2)系统可能有很多的msgqueue,每个MQ用消息队列描述符来区分(消息队列ID:qid),qid是唯一的,用于区分不同的MQ
(3)在进行进程间通信时,一个进程将消息加到MQ尾端,另一个进程从消息队列中取消息(不一定以先进先出取消息,也可按照消息类型字段取消息,但可按照消息类型先进先出,或按原本顺序先进先出)
消息队列每个元素都有类型,根据类型可以区分不同的消息
(1)创建消息队列
int msgget(key_t key, int msgflg)
key:消息队列的标识符
msgflg:创建的标志,如IPC_CREAT,记得用“|”添加上权限(0664)
返回值:返回队列ID
失败:返回-1
(2)发送消息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg)
msgid:消息队列ID
msgp:指向msgbuf的指针,用于指定发送的信息
msgp需要传递一个结构体指针,结构体时msgbuf
结构体与下方结构体类似(不一定要相同):
其中mytype是消息类型,定义的消息类型一定要大于0
mtext[1]定义了结构体当中字符数字的大小,不一定是1字节
msgsz:发送消息的长度,是结构体中mtext变量的大小
msgflg:创建标记,若指定IPC_NOWAIT,则失败会立刻返回
返回值:成功0失败-1
(3)接收消息
size_t msgrcv(int msgid, void *msgp, size_t msgsz, long msgtyp, int msgflg)
msgid:消息队列ID
msgp:指向msgbuf的指针,用于接收消息(struct msgbuf{...}),出参
msgsz:要接收消息的长度
注:参数msgsz指定由msgp参数指向的结构的成员mtext的最大大小(以字节为单位)
msgtyp指定消息类型,也有3种方式:
0:读取队列中的第一条消息(按插入顺序,先进先出)
>0:读取队列中类型为msgtyp的第一条消息。若在msgflg中指定列MSG_EXCEPT,将读取类型不等于msgtyp的队列中的第一条消息
<0:读 取队列中最小类型小于或等于msgtyp绝对值的第一条消息
msgflg:创建标记,若指定IPC_NOWAIT(非阻塞),获取失败会立刻返回
返回值:成功返回实际读取消息的字节数,失败返回-1,
(4)操作消息队列的接口
int msgctl(int msgid, int cmd, struct msgid_ds *buf)
msgid:消息队列id
cmd:控制命令
IPC_RMID,删除命令
IPC_STAT,获取状态
buf,存储消息队列的相关信息的buf
返回值:成功根据不同cmd有不同返回值,失败返回-1
eg:创建发送和接收的文件
以上代码完成了根据类型读取消息
但不能重复执行,因为接收消息文件已经读了该类型(5),再次读取已经没有类型为5的消息了,会阻塞
ipcs可以查看消息队列
key:消息队列标识
msqid:消息队列的操作句柄
owner:所有者
perms:权限
used-bytes:使用的字节数量
messages:剩余的消息数量
生命周期跟随操作系统内核
ipcrm -q [qid]可删除消息队列
软中断:告诉有这么个信号,但信号如何处理,什么时候处理是由进程决定。(只是个提醒要做什么事,不做也是可以的)
硬中断:告诉有个信号,必须要处理(提醒要做的事,必须去做)
(1)硬件产生:
如键盘:用kill -l 命令可以查看操作系统定义的信号值
ctrl+c(2号信号,SIGINT):进程收到信号,令进程终止
ctrl+z(20号信号,SIGTSTP):暂停进程
ctrl+|(3号信号,SIGQUIT):终止进程,并产生核心转储文件(core_dump)(记得改ulimit -a/c)
kill(9号信号):终止进程(强杀信号)
通过kill -[n] [pid]可以给进程号pid的进程发送值为n的信号
(2)软件产生
与信号相关函数需要包含头文件#include
kill函数
int kill(pid_t pid, int sig);
给某个pid进程发送sig信号
eg:
raise(sig)
作用:给当前进程发送sig信号
非可靠信号(非实时信号)
1-31号属于非可靠信号
可靠信号(实时信号)
34-64为可靠信号
一共有62个信号(没有32、33号信号)
区别
非可靠信号可能存在数据丢失,可靠信号一定不会丢失信号
操作系统中已经定义好信号的处理方式
忽略处理(不处理),如僵尸进程:子进程先退出,退出时告知父进程(信号),而父进程收到后忽略处理,导致其没有回收子进程的状态信息,使其变为僵尸进程
子进程告诉父进程的信号时SIGCHILD信号,该信号的处理方式为忽略处理
用户可以改变信号的处理方式,可定义一个函数,当进程收到信号后,会调用这个函数
操作系统有多个进程,内核会将信号注入到每个进程中
注册:一个进程收到一个信号的过程
信号的注册与注销是两个独立的过程
每个进程(task_struct)中都有自己独有的注册位图(未决位图(还没有处理),struct sigpending pending)与sigqueue队列
由下列代码可见,注册位图为一个结构体,里面存放了一个双向链表与另一个结构体,该结构体存放了一个long类型(8字节,1个字节8个比特位,共64个比特位,代表了之前的信号集)的数组,该数组使用按照比特位使用,一个比特位代表一个信号。当进程收到某个信号,则该信号对应的比特位就会置为1,代表其收到信号。
struct sigpending{
struct list head list;//双向链表
sigset_t signal;
};
typedef struct{
unsigned long sig[_NSIG_WORDS];
};
总结:信号对应的比特位修改为1,添加sigqueue结点到sigqueue队列中
区分非实时信号与实时信号的注册
非实时信号第一次注册:修改sig位图,添加sigqueue结点
非实时信号第二次注册(同一个非实时信号在还没有被处理情况下再次注册):修改sig位图,但不会添加sigqueue结点(相当于对于sig信号,队列中只有一个这样的结点)
实时信号第一次注册:修改sig位图,添加sigqueue结点
实时信号第二次注册:修改sig位图,再次添加sigqueue结点
内核修改信号注册相关数据结构的过程是注册,进程收到信号后有三种处理方式,处理后需要将信号注销
注销是进程执行的,注册是操作系统执行的
注销时,会将sig位图中信号对应的比特位置零,并将对应信号的sigqueue结点(只有一个,见5.5节信号注册内容)进行出队
将对应的信号的sigqueue结点进行出队,判断队列中是否还有相同结点,有则比特位不变,无则将比特位置零
sighandler_t signal(int signum, sighandler_t handler)
signum:信号值
handler:是个函数指针,接收一个函数地址,更改为目标函数处理,其指向的那个函数定义中,参数必须有个sig参数来表示“回调该函数的信号值”
作用:将信号的处理方式替换为函数指针保存的函数地址对应的函数
无返回值,参数类型int
注:当执行signal函数时,并不会调用指针对应的函数,只有当进程收到目标信号后,才会回调指针对应的函数
eg:
当没有收到信号时,会一直打印“aaa”,当给其发送2号信号后,才会打印hello...
9号信号(强杀信号)不能被自定义,因为如果可以,有可能所有信号都被自定义处理,从而导致程序无法关闭
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)
signum:信号值
act:将信号的处理方式更改为act,输入型参数
oldact:原来信号的处理方式,输出型参数
struct sigaction{
void (*sa_handler) (int);//保存信号处理方式(默认)的函数指针
void (*sa_sigaction)(int, siginfo_t *, void *);//也是保存信号的处理方式的函数指针,未使用
//若要使用,配合sa_flags一起使用
//当sa_flags的值为SA_SIGINFO时,按照sa_sigaction保存的函数地址进行处理
sigset_t sa_mask;//是个位图,当进程处理信号时,若还收到了信号,则将该信号先放入位图,后续会才放到进程的信号位图中
};
eg:
注意:需要额外包含#
执行结果如下:
如果定义了原本信号的方法,则第二次发送2号信号时会调用原本的2号信号的代码,令程序终止
从内核角度解释两个函数
在进程task_struct中,有个struct sighand_struct *sighand指针,指向struct sighand struct结构体,这个结构体中也有个结构体(struct k_sigaction action{...}),这个结构体里就有个sigaction结构体,sigaction函数的第二个结构体指针参数就相当于修改了sigaction结构体,而signal函数中的函数指针对应的就是sigaction结构体中的sa_handler函数指针。
当操作系统内核给进程注册信号后,进程需要处理他,这就是“捕捉”
处理的方式有三种(5.4节所述3种处理方式)
进程的代码在内核空间中(stuct task_struct{...})
注册:操作系统修改task_struct结构体
程序有可能在用户空间执行,也可能在内核空间执行,如调用fork创建子进程时,会切换到内核执行内核代码(fork的实现代码)。若程序一直在用户空间执行,则不可能处理信号,因为无法看到内核有没有修改task_struct。因此只有当进程进入到内核之后,才能处理信号
对上图的理解
当从用户空间进入内核空间,将系统调用函数运行完之后,需要返回用户空间,在此之前,会先调用do_signal函数处理信号(其实处理前还要判断一下信号是否被阻塞,见5.9.1的内容),如果进程没有收到信号,则直接返回用户空间;若收到了信号,需要处理信号(5.4节的三种方式),如果是默认处理或忽略处理,会在OS中直接处理掉;如果是自定义处理,会在用户空间执行其代码,当处理完后会调用sys_sigreturn()回到内核,再次调用do_signal函数,再看有没有信号,重复上述过程,直到没有信号回到用户空间(处理完毕,注销信号,返回用户空间)。
(1)信号注册和信号阻塞不同,信号的阻塞不会干扰信号的注册,而是进程收到这个信号后,暂时不处理信号。
(2)信号的处理时机:从内核切换回用户态会调用do_signal函数处理信号,若有信号则判断信号是否被阻塞(通过block位图查看),是则暂不处理回到用户态,否则处理(三种方式)
在task_struct结构体中,有pending位图,表示进程收到了哪些信号(0代表没有,1代表有),block也是位图(sigset_t,0表示不阻塞,1表示阻塞)
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how:想让sigprocmask做什么
SIG_BLOCK:设置某个信号为阻塞状态
SIG_UNBLOCK:设置某个信号为非阻塞状态
SIG_SETMASK:用第二个参数set来替换原来的阻塞位图
set:新设置的阻塞位图
oldset:原来旧的阻塞位图
SIG_BLOCK原理:如果需要用新的BLOCK位图,可用 “老的 | 新的” 即可
eg:
old: 00000000
new:01000000
二者按位或:01000000,会将目标信号对应位置置为1
SIG_UNBLOCK原理:若后来需要解除这个信号,则用 老的 & !新的
如,此时要解除01000000,其取反为10111111,令其 & 01000000
结果为00000000,成功将信号解除
设置位图的比特位
(1)int sigemptyset(sigset_t *set)
清空位图,将比特位全部置为1
(2)int sigfillset(sigset_t *set)
将比特位全部置为1
(3)int sigaddset(sigset_t *set, int signum)
将某个信号对应的比特位置为1
(4)int sigdelset(sigset_t *set, int signum)
将某个信号对应比特位置为0
(5)int sigismember(const sigset_t *set, int signum)
判断某个信号是否在set中,即判断某个型号的比特位是否为1
eg
该程序会将2号信号阻塞(ctrl+c),首先清空掉比特位,然后将2号信号的比特位置为1,之后调用sigprocmask将其阻塞,最后令程序不断sleep,从而可以用ctrl+c测试
在使用sigprocmask遇到的问题,报错说不认识“SIG_BLOCK”,需要对makefile进行修改-std=gnu99
执行结果:由图可见,发送2号信号后并没有结束该进程
eg:证明信号阻塞不会干扰信号注册,证明可靠信号不会丢信号,非可靠信号可能丢信号
如下程序,先自定义两个信号,2号和40号分别是非可靠、可靠信号,自定义后将两个信号先阻塞,然后各自发送5次信号,之后解除阻塞(所有比特位置零),会发现非可靠信号只收到了1次(有信号丢失),可靠信号收到了5次。
问题描述:父进程创建子进程,调用进程等待接口(wait(阻塞)或waitpid(可以非阻塞))防止子进程变为僵尸进程,但如此会令父进程陷入阻塞等待,直到子进程退出,因此父进程在调用wait函数时什么都干不了。虽然waitpid有非阻塞属性,但该函数通常需要搭配循环使用,此时,若子进程长时间不退出,父进程是一直循环调用waitpid的。综上,是否可以将父进程从wait或waitpid的等待中解放出来,令其执行其他逻辑代码
解决办法:
父进程创建子进程,当子进程结束时,会向父进程发送SIGCHLD信号,可以自定义这个函数,令其调用wait/waitpid函数,这样父进程在子进程结束前也可以运行其他逻辑代码了,且防止了子进程退出时变为僵尸进程
eg:
注:子进程退出时会发送SIGCHLD信号,值为17
作用:保证内存可见性
如:int i=10,若cpu需要处理i时,会将i先读取到寄存器中,然后给到cpu,而volatile则令cpu在处理时,仍然是从内存中拿传递给寄存器,再给cpu,而不是为了效率从寄存器中获取
这里需要提及到gcc/g++的编译优化选项
-O0、-O1、-O2、-O3,优化等级,等级越高速度越快
作用是如果数据已经在寄存器中存在,则cpuu会直接从寄存器中拿
eg:验证优化选项以及volatile的作用
如下代码,将2号信号自定义,该程序一开始为死循环,当收到2号信号,会将val改为0从而打印 退出(以上是数据从内存->寄存器->cpu的情况)
为了令其从寄存器中读取数据,编译时设置优化选项
由图可见,使用ctrl +c发送2号信号后并没有结束,这是因为设置了其从寄存器中读取数据,而寄存器中val=1,因此没有退出循环,可以添加volatile关键字,令其必须从内存取数据
如下:给val设置volatile关键字,优化选项仍然选择-O3令其从寄存器拿数据, 但由执行结果可见,由于关键字volatile的影响,其会强制从内存拿数据
类似于流水线和工厂的关系。一个工厂是个一个进程,工厂中的一个个流水线就是线程
线程是某个进程中的某一条执行路线。
线程依附于进程存在,若没有进程,则线程不会单独存在
多个线程是为了提高整个程序的运行效率的
综上:线程也被称为“执行流”,执行用户写的代码
一个进程中的多个线程是多个task_struct结构体,每个结构体中有个内存指针,都指向一个共同的程序地址空间,在其中的共享区,会给每个线程创建各自的空间
操作系统中没有线程概念,目前所说的“创建线程”,本质上是在操作系统中创建轻量级进程(等价于线程)。
对于曾经所写的代码也存在线程,就是执行main函数的执行流,称为“主线程”
曾经所写的代码,就只有1条“流水线”
进程在内核中是个task_struct结构体,在结构体中成员变量pid为进程号,而task_struct中还有一个tgid,叫做线程组ID(进程号),pid是(进程id),tid是线程号,不同线程的tid不同,但是同一个进程的线程的tgid相同(属于一个进程的认为是一个线程组)
因为主线程的pid和tgid相同
工作线程的pid和tgid一定不相同(这个自己测试的时候发现其实是一样的,这个说法的准确性不确定)
eg:
一个进程当中的不同线程,拥有相同的tgid
父进程(主线程)创建子进程后,父进程和子进程的tgid相同,但是父子的pid一定不同
进程是操作系统分配资源的最小单位(一个进程,对应的是一个虚拟地址空间)
线程是操作系统调度的最小单位
在进程虚拟地址空间中,会在共享区为每个线程分配空间,其中包含了线程ID、调用栈、寄存器、线程ID、errno、信号屏蔽字、调度优先级。
调用栈:(展示了函数的调用堆栈,可以看出函数调用的关系,调用时函数压栈,调用完毕时,函数栈帧销毁,其中,压栈与销毁发生在栈区,不可能出现调用者销毁,被调用者没被销毁的情况)因为若主线程与工作线程共用一个栈,会出现调用栈混乱问题,所以在共享区会给每个线程分配各自独立的调用栈。
寄存器:在内核中会为每个线程创建task_struct,通过双向链表连接,由于每个线程的执行是有时间限制的(时间片轮转),因此需要有寄存器存储各个线程切换时的内容,所以对每个线程都会有一个寄存器来记录切换的信息以便还原场景。
errno:错误码。使用一些系统调用接口时,若出错,会赋值一个错误码给errno,从而可以查看出了什么错,每个线程都有自己的错误码(每个线程出错的原因不一定相同)
调度优先级:每个线程被独立调度,因此每个线程的调度优先级不同
文件描述符表,用户id,用户组id,信号处理方式,当前进程的工作目录
优点
(1)多线程的程序,拥有多个执行流,合理使用,可以提高程序的运行效率
(2)多线程程序的线程切换比进程切换快(线程之间的共享数据在切换时可以不动,因此切换速度更快一些)
(3)可以发挥多核CPU并行的优势,对于计算密集型的程序,可以进行拆分,让不同的线程执行计算不一样的事情;对IO密集型的程序,可以长不同线程执行不同的IO操作,能够不用串行运行,提高程序运行效率
缺点
(1)代码编写难度更高
(2)代码(稳定性)鲁棒性要求更高,如果有一个线程崩溃,则整个进程会退出
(3)线程数量不是越多越好。因为线程切换需要耗费时间(随着线程数量的增多,程序运行效率逐渐提高,直到程序运行效率最高点,然后随着线程数量增多效率下降,应该会符合正态分布)
(4)缺乏访问控制(两个进程都需要同一份资源),可能会导致程序产生二义性的结果
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg)
需要包含#include
且编译时需要连接pthread这个库,用-lpthread链接
thread:获取线程标识符(地址),本质是线程独有空间的首地址
attr:线程的属性信息,一般填写NULL,采用默认的线程属性
线程属性一般关心:调用栈的大小、分离属性、调度策略、调度优先级
start_routine:函数指针,线程执行的入口函数(线程执行时,从该函数开始运行,不是从main开始)
arg:给线程入口函数传递参数
返回值:成功0失败-1
eg:
可以用pstack查看调用关系,从下往上是函数调用关系
图中LWP代表轻量级进程,后面的数字是轻量级进程ID,也就是线程ID(tid)
是否可以创建多个线程?
可以,通过多次调用pthread_create创建多个,如下代码
可以看到创建了3个线程(一个主线程、两个工作线程)
工作线程的传参
传参时,只考虑如何把参数传递给void*,进行参数获取时,只考虑如何将void*转化为对应的类型
注:如果几个线程打印同一个变量,而这个变量值发生变化,则最终几个线程打印出的变量都相同。
且若几个线程打印的变量是临时变量时会有风险,因为其是个临时变量,则其在作用域外时变量销毁,此时几个线程可以非法访问这个空间。
因此,线程入口函数的参数不要传递临时变量。临时变量出了作用域会被销毁,有可能线程在访问非法地址空间。
区分工作线程
(1)pthread_self打印线程标识符
(2)给线程分别传递不同的堆上的空间
如下,传递的指针指向堆上开辟的空间,且两个传递的指针参数指向的不是同一个空间
注:上述代码在堆上申请空间后没有释放空间,应该在每个线程自己的线程入口函数中就释放掉
void pthread_exit(void *retval)
retval:线程退出时,传递给等待线程的退出信息
作用:谁调用谁退出
eg:
int pthread_cancel(pthread_t thread)
thread:被终止线程的表示符
作用退出某个线程
eg:将上面的代码中的线程入口函数修改为:
执行结果:
由图可见,工作线程多运行了一次,这是因为pthread_cancel函数不是直接取消,而是要执行其自己的一个取消流程,执行期间足够我们的线程再运行一次
线程被创建出来时默认属性为joinable(退出时,依赖其他线程来回收资源(退出线程需要使用共享区中的空间))
int pthread_join(pthread_t thread, void **retval)
retval:线程标识符
retval:退出线程的退出信息
第一种:线程入口代码执行完退出,就是入口函数的返回值(void*)
第二种:pthread_exit推出的,为pthread_exit的参数
第三种:pthread_cancel退出,为一个宏:PTHREAD_CANCELED
该函数是阻塞调用的
一旦线程设置了分离属性,则线程退出时,不需任何其他线程回收,操作系统可以回收
int pthread_detach(pthread_t thread)
thread:设置线程分离的线程标识符
设置100张票,令2个黄牛(线程)一起抢
注:线程是被操作系统独立调度的,且线程A一定先于线程B创建,在其运行的时间片内,可能会将所有票全部抢完
如6.2.5中的程序结果,100出现在了线程1与2中,代表这两个线程都拿到了100这张票。这是由于,进程一运行后,在时间片结束时,程序计数器会保留下一次要执行的代码,且上下文信息保留了ticket这个变量的值100(*p--没有运行到),然后进程2开始运行,其也会从内存中读取信息,由于进程1中的代码中ticket还是100,所以进程2会显示剩余票量也是100,由此导致了线程不安全。
并发与并行都存在安全问题
操作系统的调度
先来先服务、优先级优先、时间片轮转、短作业优先
为何需要调度策略:CPU少,进程、线程多,为了令每个进程/线程都能有时间运行,所以设计了调度策略
如何保证线程安全:互斥
互斥:控制线程的访问时序,当多个线程能够同时访问到临界资源时,可能会导致线程执行结果产生二义性。
作用:互斥保证多个线程访问同一个临界资源,执行临界区代码时,控制访问时序,让一个线程独占临界资源执行完,再让另一个独占执行(让一个独占,等其执行完再让另一个独占)
临界资源
多个线程能够访问的资源
临界区代码
访问临界资源的代码
有两个线程A、B以及一个临界区,两个线程同时访问可能会出现不安全现象,因此,给临界区加了一把锁,两个进程必须拿到这个锁才能访问临界区,如果一个已经拿走锁,则另一个必须等待,当访问结束,将锁解开(解锁),另一个才能拿锁访问。加的这把所就是互斥锁
原理
互斥锁的本质是0/1计数器,计数器取值只能为0或1
计数器值为1:表示当前线程可以获取到互斥锁,从而访问临界资源
计数器的值为0:表示当前线程不能获取互斥锁,从而不能访问临界资源
加锁:加锁成功将计数器值从1变为0
解锁:解锁成功将计数器值从0变为1
(1)多个线程要执行临界区时,为了保证互斥,都需要先进行加锁保护
(2)极端情况下,多个线程同时进行加锁,只有一个线程可以加锁成功,其他线程加锁失败。当拿到锁的线程执行完临界区代码,需要解锁,将计数器值从0变为1,然后其他线程就可以对其进行加锁了。
为何计数器中的值从0变为1,或从1变为0是原子性的?因为操作不可被分割,要么开始,要么没开始,不存在中间状态
直接使用寄存器中的值和计数器内存的值交换,而交换是一条汇编指令就可完成的
eg:
加锁时,设置寄存器中值为0
情况1:计数器值为1,说明锁空闲,没有被线程加锁
此时会用寄存器中的值(直接给0)和计数器中的值(1)进行直接交换,此时如果判断寄存器的值为1,说明原先计数器中值为1,进而表明加锁成功
情况2:计数器值为0,说明锁忙碌,被其他线程加锁拿走
令寄存器的0和计数器的0交换,交换后,计数器值仍然为0,此时说明加锁失败,不能访问临界资源。
解锁时,设置寄存器中值为1
情况1:计数器值为1,说明锁空闲,没有被线程加锁
此时会用寄存器中的值(1)和计数器中的值(1)进行直接交换,值不变。
情况2:计数器值为0,说明锁忙碌,被其他线程加锁拿走
令寄存器的1和计数器的0交换,交换后,计数器值为1,此时说明加锁成功。
动态初始化
int pthread_mutex_init(pthread_mutex_t * mutex, const pthread_mutexattr_t * attr)
pthread_mutex_t:互斥锁类型(结构体),这个结构体是已经定义了的
attr:互斥锁的属性,一般直接给NULL
销毁
int pthread_mutex_destroy(pthread_mutex_t *mutext)
静态初始化
pthrad_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
int pthread_mutex_lock(pthread_mutex_t *mutex)
该方法阻塞加锁,直到加锁成功才返回
int pthread_mutex_trylock(pthread_mutex_t *mutex)
该方法为非阻塞加锁,若加锁不成功也会返回,该方法需要判断是否加锁成功,要搭配循环使用
int pthread_mutex_timedlock(pthread_mutex_t *mutex, const struct timespec *abs_timeout)
struct timespec{
time_t tv_sec;//秒
long tv_nsec;//纳秒
}
带有超时时间的加锁接口,若超过超时时间仍没有加锁成功,则返回
int pthread_mutex unlock(pthread_mutex_t *mutex)
综上,可对6.3节的代码进行改进,在创建线程前创建互斥锁并初始化,然后在每个线程的临界区代码中用加锁解锁来令其具有原子性,最后在所有线程结束后,销毁互斥锁。
注:如果线程调用函数中的sleep在临界区,其不会起到作用,因为其在睡眠时,没有解锁,其他的线程依然不会运行,因此需要将其放在临界区外(即解锁之后再进行sleep)
#include
#include
#include
#include
/*
* 票:100张
* 黄牛:2个(线程),对票进行--
*/
struct thread{
int *p;
int i;
pthread_mutex_t *m;
};
void* mythread(void *arg)
{
//抢票
struct thread * s = (struct thread*)arg;
int *p = s->p;
while(1){
//加锁
pthread_mutex_lock(s->m);
if((*p)<=0){
pthread_mutex_unlock(s->m);
break;
}
printf("线程:%d,剩余%d张票\n",s->i,(*p));
(*p)--;
//解锁
pthread_mutex_unlock(s->m);
sleep(1);
}
}
int main()
{
//互斥锁初始化
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
int tickets = 100;
pthread_t tid[2];
for(int i=0; i<2;i++){
struct thread *t = (struct thread*)malloc(sizeof(struct thread));
t->i = i+1;
t->p = &tickets;//两个指向同一个空间
t->m = &mutex;
pthread_create(&tid[i], NULL, mythread, t);
}
for(int i=0;i<2;i++){
pthread_join(tid[i], NULL);//令其进行线程等待
}
//销毁互斥锁
pthread_mutex_destroy(&mutex);
return 0;
}
注:在线程所有可能退出的地方解锁,如上述程序中线程执行代码中的if判断语句部分
互斥的优缺点
互斥可以保证多个线程对临界资源访问的互斥属性
如果只保证互斥,可能出现线程饥饿问题(某个线程一直拿不到锁)
如何解决饥饿问题?同步
线程同步:在保证互斥的前提下,保证多个线程对临界资源访问的合理性
条件变量原理
线程在加锁之后,判断临界资源是否可用:如果可用,则直接访问临界资源,否则就调用等待接口,让该线程进行等待,当临界资源可用了时,唤醒等待队列中的线程,令其加锁...
添加变量本质上是PCB等待队列(存放在等待的线程的PCB)
初始化
动态初始化
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr)
pthread_cond_t:条件变量的类型
pthread_condattr_t:NULL,采用默认属性
静态初始化
int pthread_cond_t cond = PTHREAD_CONDINITIALIZER
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
传递的是条件变量
等待
将线程放入等待队列
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex)
一直阻塞等待,直到临界资源可用
作用:谁调用将谁放入PCB等待队列中
cond:条件变量
mutex:互斥锁
注:这个接口内部会对调用它的线程进行解锁(如果不解锁,那么其他线程无法加锁执行其它代码),等到其他线程执行完解锁后,这个函数会令当前线程和其他线程抢锁,若抢到,函数返回,没抢到函数继续阻塞,直到抢到锁
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime)
带有超时时间的等待,时间内阻塞,超过时间则返回
唤醒
通知PCB等待队列,唤醒在队列中的线程
int pthread_cond_broadcast(pthread_cond_t *cond)//唤醒所有
int pthread_cond_signal(pthread_cond_t *cond)//唤醒部分,至少唤醒一个
令一个人一直吃面,一个人一直做面,且吃面的前提是碗里必须有面,做面必须是碗里没面了才做。
先利用之前的互斥,令两个人在吃面时一定不会做面,做面时另一个人一定不在吃面,但是,以下代码并没有保证做面是在碗里没有面的情况下做的,或吃面是在碗里有面的情况下吃,因此需要利用先前学习的同步
#include
#include
#include
#include
//一个人吃面,一个人做面,相当于有两个线程
struct Thread{
int bowl;
int tid;
pthread_mutex_t *mutex;//互斥锁参数
};
void *eat_start(void *arg)
{
//吃面之后,应该没有面
struct Thread * t = (struct Thread*)arg;
while(1){
//加锁
pthread_mutex_lock(t->mutex);
printf("吃面人,吃了:%d\n", t->bowl);
t->bowl--;
//解锁
pthread_mutex_unlock(t->mutex);
}
return NULL;
}
void *do_start(void *arg)
{
struct Thread *t = (struct Thread*)arg;
while(1){
//加锁
pthread_mutex_lock(t->mutex);
printf("做面人,做了%d\n", t->bowl);
t->bowl++;
//解锁
pthread_mutex_unlock(t->mutex);
}
return NULL;
}
int main()
{
//定义互斥对象,碗
int bowl = 0;//0:没面,1:有面
//定义互斥锁变量
pthread_mutex_t mutex;
//初始化互斥锁
pthread_mutex_init(&mutex, NULL);
//创建吃面线程与做面线程
pthread_t tid;
struct Thread * t1 = (struct Thread*)malloc(sizeof(struct Thread));
t1->bowl = bowl;
t1->tid = 1;
t1->mutex = &mutex;
int ret = pthread_create(&tid, NULL, eat_start, t1);//吃面
if(ret<0){
perror("pthread_create");
return 0;
}
pthread_t tid2;
struct Thread*t2 = (struct Thread*)malloc(sizeof(struct Thread));
t2->bowl=bowl;
t2->tid=2;
t2->mutex = &mutex;
int ret2 = pthread_create(&tid2, NULL, do_start, t2);//做面
if(ret2<0){
perror("pthread_create");
return 0;
}
//主线程进行线程等待
pthread_join(tid, NULL);
pthread_join(tid2, NULL);
//销毁互斥锁
pthread_mutex_destroy(&mutex);
}
因此,对其添加条件变量,最终代码如下,改代码实现了:如果有60个碗,一个做面的人一直做,另一个一直吃。且做的时候要保证有空碗,吃的时候要保证至少有1碗面
添加条件变量或互斥锁的步骤类似
定义条件变量/互斥锁->初始化条件变量或互斥锁(init函数)->销毁(destroy)->加锁/等待->解锁/唤醒
加锁和解锁之间的代码是保证代码的原子性,令两个人的代码互斥,即一个人做面时另一个人没有进行吃面程序,等待和唤醒是保证两个人在执行代码前满足一定的前提(有空碗或者有面)
#include
#include
#include
#include
//一个人吃面,一个人做面,相当于有两个线程
struct Thread{
int *bowl;
int tid;
pthread_mutex_t *mutex;//互斥锁参数
pthread_cond_t * cond;//条件变量参数
};
void *eat_start(void *arg)
{
//吃面之后,应该没有面
struct Thread * t = (struct Thread*)arg;
while(1){
//加锁
pthread_mutex_lock(t->mutex);
if(*(t->bowl)<=0){
pthread_cond_wait(t->cond, t->mutex);//参数是条件变量和互斥锁
}
printf("吃面人,当前有:%d碗面,吃了一碗\n", *(t->bowl));
(*(t->bowl))-=1;
//解锁
pthread_mutex_unlock(t->mutex);
//唤醒,通知说吃完了
pthread_cond_signal(t->cond);
}
return NULL;
}
void *do_start(void *arg)
{
struct Thread *t = (struct Thread*)arg;
while(1){
//加锁
pthread_mutex_lock(t->mutex);
if(*(t->bowl)>60){
pthread_cond_wait(t->cond, t->mutex);
}
printf("做面人,当前有%d碗面,做了1碗\n", *(t->bowl));
(*(t->bowl))+=1;
//解锁
pthread_mutex_unlock(t->mutex);
//唤醒,面做好了,要叫人来吃
pthread_cond_signal(t->cond);
}
return NULL;
}
int main()
{
//定义互斥对象,碗
int bowl = 0;//0:没面,1:有面
//定义互斥锁变量
pthread_mutex_t mutex;
//初始化互斥锁
pthread_mutex_init(&mutex, NULL);
//定义条件变量
pthread_cond_t cond;
//条件变量初始化
pthread_cond_init(&cond, NULL);
//创建吃面线程与做面线程
pthread_t tid;
struct Thread * t1 = (struct Thread*)malloc(sizeof(struct Thread));
t1->bowl = &bowl;
t1->tid = 1;
t1->mutex = &mutex;
t1->cond = &cond;
int ret = pthread_create(&tid, NULL, eat_start, t1);//吃面
if(ret<0){
perror("pthread_create");
return 0;
}
pthread_t tid2;
struct Thread*t2 = (struct Thread*)malloc(sizeof(struct Thread));
t2->bowl=&bowl;
t2->tid=2;
t2->mutex = &mutex;
t2->cond = &cond;
int ret2 = pthread_create(&tid2, NULL, do_start, t2);//做面
if(ret2<0){
perror("pthread_create");
return 0;
}
//主线程进行线程等待
pthread_join(tid, NULL);
pthread_join(tid2, NULL);
//销毁互斥锁
pthread_mutex_destroy(&mutex);
//销毁条件变量
pthread_cond_destroy(&cond);
}
假设有两个人吃面,两个人做面,则四个人抢锁,初始时假设有面,若做面人1抢到了锁,对他来说资源不可用(有面则不做),因此做面人1放入等待队列,此时其他三个人抢锁,若做面人2抢到了锁,依然有面,放入等待队列,然后吃面人1抢到锁,吃完面后通知,然后等待队列中的做面人要被通知出来抢锁然后做面。
综上,当线程等待后,需要等到下一次抢到锁才能继续,但是问题是即使下一次抢到了锁,也不一定就有资源(有面吃),所以应该用while来进行判断,而非if
还有一个问题:可能会出现四个人全部进入等待队列等待,从而导致永久等待现象
eg:一开始空碗,吃1抢锁,但没得吃,进入等待,吃2抢锁,进入等待,做1抢锁,做面,唤醒吃1,此时吃1需要和做1、做2抢锁,结果做1、做2抢到了锁,但是此时是有面的,所以做1、做2进入等待,此时只剩下了吃1,其抢锁,吃了一碗,唤醒了吃2,结果现在没有面,且没有人做面,因此,吃1、吃2也陷入等待。
解决办法:给做面人和吃面人分别定义条件变量,当吃面人执行完代码唤醒做面人,反之亦然,防止唤醒了同类.且吃不了时放入吃面人的等待队列,做面人做不了时放入做面人的等待队列
(1)条件变量的等待接口的第二个参数为何有互斥锁
在函数内部需要解锁操作。如果不解锁就阻塞等待,则其他线程一定拿不到锁
(2)pthread_cond_wait的内部是针对互斥锁做了什么操作?先释放互斥锁还是先将线程放如PCB等待队列再解锁
先放入PCB等待队列再解锁
(3)线程被唤醒后执行什么代码,是否需要再获取互斥锁
首先从PCB等待队列中移除,然后抢锁
(1)不解锁
(2)吃着碗里的,看着锅里的
线程A加锁1, 线程B加锁2,此时线程A还想加锁2,且线程B想加锁1,而要加锁2需要先解锁2,而此时锁2和B连接,但是B又想加锁1,也是处于等待,由此产生死锁。
程序中的sleep作用:若线程A线运行,则令线程B先把锁2拿走,则接下来线程A想要锁2就会等待,紧接着线程B想要锁1也会等待,造成死锁。
由pstack结果可见程序死锁
如何调试正在运行的代码:
gdb attach [pid]调试正在运行的程序
thread apply all bt调试命令,对每个线程查看调用堆栈
bt:back trace,调用堆栈
t [线程编号]:调试某一个线程
f [编号]:跳转都其中一个调用的程序中(一般跳转到线程入口函数中)
eg:
通过f命令,以及p 变量名 命令可见mutex1的拥有者
由此可见,线程2想要添加锁1,而锁1的拥有者是线程3,对线程1调试的结果与此相反
不可剥夺(不可改变):线程获取到互斥锁之后,除了自己释放,其他线程不能进行释放
循环等待(可改变):线程A拿着1锁,请求2锁,同时线程B拿着2锁,请求1锁
互斥条件(不可改变):一个互斥锁,在同一时间只能被一个线程所拥有
请求与保持(可改变):吃着碗里,看着锅里
不可剥夺与互斥条件是互斥锁的基础属性,程序员无法通过代码更改,但可以通过代码破坏循环等待与请求与保持条件。
破坏必要条件:循环等待/请求与保持
(1) 对于上图所示的死锁场景,可以将加锁属性设置为非阻塞或设置超时时间,防止其死锁
设置非阻塞:pthread_mutex_trylock(&mutex)
设置超时时间:pthread_mutex_timedlock(&mutex)
可根据函数返回值判断是否加锁成功,若失败可以将互斥锁释放
(2)写代码时,注意加锁顺序,都先加1锁,再加2锁
(3)避免锁没有被释放,在所有可能线程退出的地方进行解锁
(4)资源一次性分配:多个资源在代码中有可能每个资源都需要使用不同的锁进行保护。如之前的两个线程互相请求对方的锁,可以更改为两个线程同时被1把锁保护
1个线程安全的队列:满足先进先出,互斥、同步
2种角色的线程:生产者、消费者
生成者负责向队列中放元素,消费者负责从队列中拿元素
3个规则
生产者与生产者互斥(两个生产者不能同时向队列放数据)
消费者与消费者互斥(两个消费者不能同时取数据)
生产者与消费者互斥+同步:生产者生产时消费者不能消费,反之亦然(互斥);消费者消费结束要通知生产者(同步),反之亦然;
该模型中,生产者只关心生产以及队列是否有空闲空间;消费者只关心消费以及队列中是否有数据;
模型中的队列:为生产者、消费者起到了缓冲作用
生产者不用因为没有人消费发愁,只需将生产的数据放入队列中
消费者不用担心生产者生产了大量数据,只需要关心正在处理的数据即可
模型的优点:
将生产者与消费者解耦
支持忙闲不均:队列进行缓存
支持高并发:能够处理多个请求,即令每个线程处理不同请求
代码实现
(1)实现线程安全的队列
队列:queue
线程安全:互斥、同步
(2)两种角色的线程
头文件中,定义一下安全队列,队列中的插入、删除操作应该具有原子性(防止插入相同的值)
#include
#include
#include
#include
#include
#include
using namespace std;
class Safe_queue{//线程安全的队列
public:
Safe_queue(){
//互斥锁初始化,条件变量初始化
pthread_mutex_init(&this->que_lock, NULL);
pthread_cond_init(&this->cons_cond, NULL);
pthread_cond_init(&this->prod_cond, NULL);
capacity = 1;
}
~Safe_queue(){
//销毁互斥锁
pthread_mutex_destroy(&this->que_lock);
pthread_cond_destroy(&this->cons_cond);
pthread_cond_destroy(&this->prod_cond);
}
//插入
void Push(int data){
pthread_mutex_lock(&que_lock);//不能同时插入
while(que.size()>=capacity){
pthread_cond_wait(&this->prod_cond, &que_lock);
}
this->que.push(data);
cout<<"生产者"<que.front();
this->que.pop();
cout<<"消费者"< que;
int capacity;//队列的容量
pthread_mutex_t que_lock;//互斥锁
pthread_cond_t cons_cond;//消费者条件变量
pthread_cond_t prod_cond;//生产者条件变量
};
struct thread{
pthread_t tid;
int *data;//存储消费或生产了多少数据
Safe_queue *sq;
};
#include"myque.h"
#define THREAD_NUMS 10
void *cons_start(void *arg)
{
//其一辈子进行生产,生产的数据应该向线程安全队列中
struct thread *t = (struct thread*) arg;
Safe_queue *sq = t->sq;
int &n = *(t->data);
while(1){
if(n>999){
break;
}
sq->Pop();
n++;
}
return NULL;
}
void *prod_start(void *arg)
{
struct thread *t = (struct thread*) arg;
Safe_queue *sq = t->sq;
int &data = *(t->data);
while(1){
if(data>1000){
break;
}
sq->Push(data);
data++;
}
return NULL;
}
int main()
{
//创建两个线程,分别代表消费者与生产值
vector cons;//代表消费者的id
vector prod;//代表生产者的id
//线程安全队列
Safe_queue *sq = new Safe_queue();
int n1=0;
int n2=0;
struct thread * t1 = new struct thread;//消费者
t1->data = &n1;
t1->sq = sq;
struct thread * t2 = new struct thread;//生产者
t2->data = &n2;
t2->sq = sq;
for(int i=0; i
注:上述代码仍然可能打印相同值,因为在获取data指针时可能两个生产者线程会获取值相同的地址,由此在打印时导致值相同
6.7信号量
信号量:既可以完成互斥,也可完成同步
同步是用于配合判断资源是否可用从而将线程放入/取出等待队列,但是是否可用是程序员来进行的,信号量则可以将资源维护起来。
即信号量相当于可以判断资源是否可用,从而将线程放入/取出等待队列。其可以判断资源是否可用,其判断了资源的数量,动态维护资源数量。
6.7.1信号量的原理
资源计数器 + PCB等待队列
资源计数器
描述资源的可用情况
执行流获取信号量,获取成功,信号量计数器减一,获取失败,执行流放入PCB等待队列‘;执行流释放信号量成功后,计数器加一操作
PCB等待队列
资源不可用时,线程会被放入PCB等待队列进行等待
eg:如图所示,当生产者抢到锁,调用信号量的等待接口,获取到生产者的信号量, 发现资源计数器值为4,说明有4个空闲空间,获取成功,然后对信号量-1操作变为3,生产者线程允许操作临界资源队列,之后通知消费者信号量,令其资源计数器+1,此时资源的个数由信号量进行维护;
在使用了临界资源后,应该还要释放信号量:调用信号量的释放接口,若是生产者执行,则对消费者资源计数器+1;若消费者执行则生产者资源计数器+1
6.7.2信号量的接口
头文件:#include
int sem_init(sem_t *sem, int pshared, unsigned int value)
sem:信号量,sem_t为信号量类型
pshared:该信号量时用于线程间还是用于进程间
0:用于线程间
非0:进程间
将信号量所用的资源在共享内存中开辟
value:资源的个数,初始化信号量计数器
等待接口:
int sem_wait(sem_t *sem)
作用:
(1)对资源计数器减一操作
(2)判断资源计数器值是否小于0
是:阻塞等待,将执行流放入PCB等待队列中
不是:接口返回
释放接口
int sem_post(sem_t *set)
会对资源计数器进行+1操作
判断资源计数器的值是否小于等于0
是:通知PCB等待队列
否:不通知PCB等待队列,因为没有线程在等待
销毁接口
int sem_destroy(sem_t *sem)
eg:测试等待接口的阻塞属性:
由图可知,程序的确被sem_wait阻塞了
再有如下程序:测试释放接口释放信号量的+1
一开始,可以显示hello,因为一开始有一个资源,当执行完后,资源计数器-1,当第二次调用wait时,由于没有资源造成了阻塞,然后通过发送ctrl+c信号,调用sem_post接口,令资源计数器+1,从而有了资源,wait不再阻塞,打印world
6.7.3信号量完成互斥
资源计数器的值只为0/1,0代表资源不可用,1代表可用,初始值为1,则若有两个线程AB,当A访问,计数器值-1=0,然后使用资源,与此同时,B如果也想访问,会因为计数器值变为0而访问不了,造成阻塞等待,当A使用完毕,释放信号量,资源计数器+1,此时B可以访问。
#include
#include
#include
#include
#include
sem_t sem;
int tickets = 100;
void *thread_startA(void* arg)
{
pthread_detach(pthread_self());//分离,令操作系统回收
while(1){
sem_wait(&sem);//等待信号量,可认为是在加锁
if(tickets<=0){
sem_post(&sem);
break;
}
printf("我是线程1, 拥有%dticket\n", tickets--);
sem_post(&sem);//释放信号量,可认为是在解锁
sleep(1);
}
}
void *thread_startB(void* arg)
{
pthread_detach(pthread_self());
while(1){
sem_wait(&sem);
if(tickets<=0){
sem_post(&sem);
break;
}
printf("我是线程2, 拥有%dticket\n", tickets--);
sem_post(&sem);
sleep(1);
}
}
int main()
{
//初始化信号量,注:这一步要在创建线程之前
sem_init(&sem, 0, 1);//初始化,一开始一个资源
pthread_t tid;
pthread_t tid2;
//创建两个线程
int ret = pthread_create(&tid, NULL, thread_startA, NULL);
if(ret<0){
perror("pthread_create");
return 0;
}
ret = pthread_create(&tid2, NULL, thread_startB, NULL);
if(ret<0){
perror("pthread_create");
return 0;
}
//利用信号量保证互斥
while(1){
}
//销毁信号量
sem_destroy(&sem);
return 0;
}
6.7.4代码实现6.7.1节中的示意图
一下程序主要需要注意,定义了三个信号量,sem是为了令生产者和消费者的互斥,其余两个是为了令生产者之间互斥,以及消费者之间互斥,以及消费者和生产者的同步
#include
#include
#include
#include
#include
#include
#include
using namespace std;
class Safe_queue{//线程安全的队列
public:
Safe_queue(){
//互斥锁初始化,条件变量初始化
capacity = 1;
sem_init(&sem, 0, 1);//互斥初始化时一个资源
sem_init(&cons_sem, 0, 0);//消费者一开始没有资源
sem_init(&prod_sem, 0, capacity);//生产者初始化根据队列容量决定
}
~Safe_queue(){
//销毁锁
sem_destroy(&sem);
sem_destroy(&cons_sem);
sem_destroy(&prod_sem);
}
//插入
void Push(int data){
//获取生产者信号量
sem_wait(&prod_sem);
sem_wait(&sem);//加锁
this->que.push(data);
cout<<"生产者"<Safe_queue::que.front();
this->que.pop();
cout<<"消费者"< que;
int capacity;//队列的容量
sem_t sem;//用于保证互斥的信号量
//保证同步
sem_t prod_sem;//生产者的信号量
sem_t cons_sem;//消费者的信号量
};
struct thread{
pthread_t tid;
int *data;//存储消费或生产了多少数据
Safe_queue *sq;
};
#include
#include
#include
#include
#include
#include"myque.h"
using namespace std;
#define NUMS 1
void *cons_thread(void *arg)
{
Safe_queue *sq = (Safe_queue*)arg;
int data=0;
while(1){
sq->Push(data);
data++;
}
return NULL;
}
void *prod_thread(void*arg)
{
Safe_queue *sq = (Safe_queue*)arg;
while(1){
sq->Pop();
}
return NULL;
}
int main()
{
Safe_queue *sq = new Safe_queue();
vector cons;
vector prod;
for(int i=0;i
6.8线程池
多线程程序是为了解决程序运行效率的问题
单线程代码一定是串行运行的
线程池不仅要提高程序运行效率(创建多个线程),还要提高程序处理业务的种类
当业务种类较少时,可以采用switch或if语句列举,但是当业务种类较多时,以上分支语句不适用
6.8.1线程池的原理
线程池的原理=一堆线程+线程安全的队列(元素带有任务接口)
插入队列时,需要插入队列元素的类型
队列元素的类型=需要处理的数据+处理函数,当线程拿走队列元素时,其只需要调用处理数据的对应的函数,从而处理数据。
线程池创建了固定数量的线程,循环从任务队列中获取任务对象(队列元素=数据+函数)
获取到任务对象后,执行任务对象的任务接口(函数处理数据)
6.8.2代码
线程安全的队列
线程安全:互斥+同步,或用信号量
元素类型包括:数据,处理数据的函数(任务接口)
队列:STL queue
定义线程池
线程安全的队列
一堆的线程
eg:说明元素类型的代码,在这个队列中,存放了数据以及一个函数指针,分别对应之前所述的数据以及任务接口(处理数据的函数),这里采用了重命名函数指针类型的方法,将指针类型重定义为handler
关于函数指针、指针函数的定义:
C语言:函数指针及定义方式、函数指针作函数参数、回调函数_c语言定义函数指针_NewsomTech的博客-CSDN博客
#include
#include
#include
#include
using namespace std;
//定义队列元素的类型
// 数据
// 处理数据的方法
//
typedef void (*handler)(int data);//函数指针,返回值 (*指针名)(参数) = 函数
class Que{
public:
Que()
{
}
Que(int data, handler hand)
{
this->data = data;
this->hand = hand;
}
void run()
{
hand(data);
}
private:
int data;//要处理的数据
//处理数据的方法:函数地址
handler hand;//保存函数的地址
};
void Deal1(int data)
{
cout<<"i am 1, i deal"<run();
q2->run();
delete(q);
return 0;
}
若线程入口函数在类中定义,会传递两个指针,this与arg,想令其不传递this,可以添加static关键字,转变为静态成员函数
根据线程池的定义,实现的代码如下:
该部分实现了一个数据类、一个安全队列、一个线程池类
#include
#include
#include
#include
#include
using namespace std;
//定义队列元素的类型
// 数据
// 处理数据的方法
//
typedef void (*handler)(int data);//函数指针,返回值 (*指针名)(参数) = 函数
class Que_data{
public:
Que_data()
{
}
Que_data(int data, handler hand)
{
this->data = data;
this->hand = hand;
}
void run()
{
hand(data);
}
private:
int data;//要处理的数据
//处理数据的方法:函数地址
handler hand;//保存函数的地址
};
void Deal1(int data)
{
cout<<"i am 1, i deal"<mutex, NULL);
//条件变量初始化
pthread_cond_init(&this->prod_cond, NULL);
pthread_cond_init(&this->cons_cond, NULL);
}
~SafeQue(){
pthread_mutex_destroy(&this->mutex);
pthread_cond_destroy(&this->prod_cond);
pthread_cond_destroy(&this->cons_cond);
}
void Push(Que_data &data)
{
//加锁
pthread_mutex_lock(&mutex);//互斥
//同步
while(que.size()>=capacity){
pthread_cond_wait(&prod_cond, &mutex);
}
que.push(data);
//解锁
pthread_mutex_unlock(&mutex);
//通知消费者
pthread_cond_signal(&cons_cond);
}
Que_data Pop(int flag){
//加锁
pthread_mutex_lock(&mutex);
while(que.size()==0){//此时队列中没有元素,应该在这里添加退出
if(flag==1){
pthread_mutex_unlock(&mutex);
pthread_exit(NULL);
}
pthread_cond_wait(&cons_cond, &mutex);
}
Que_data d = que.front();
que.pop();
//解锁
pthread_mutex_unlock(&mutex);
//通知生产者
pthread_cond_signal(&prod_cond);
return d;
}
void callAllThread()
{
//解锁线程
pthread_mutex_unlock(&mutex);//令其能拿到锁,然后唤醒所有的消费者线程
pthread_cond_broadcast(&cons_cond);//将消费者线程全部唤醒
}
private:
queue que;
int capacity;
//互斥锁
pthread_mutex_t mutex;
//同步
pthread_cond_t cond;
pthread_cond_t prod_cond;//生产者条件变量
pthread_cond_t cons_cond;//消费者条件变量
};
class ThreadPool{
public:
ThreadPool()
{
}
~ThreadPool()
{
if(this->sq!=NULL){
delete this->sq;//释放动态开辟的空间
}
}
int InitThreadPool(int thread_count_)
{
this->flag = 0;//一开始为0,代表线程继续运行,1代表退出
this->sq = new SafeQue;
this->thread_count = thread_count_;
for(int i=0;isq->Push(qd);
}
//线程入口函数
static void *worker_start(void * arg)
{
//分离进程
pthread_detach(pthread_self());
//从队列中拿元素
//处理元素
ThreadPool * tp = (ThreadPool*) arg;
while(1){//工作线程如果不干预,不会退出
Que_data data = tp->sq->Pop(tp->flag);//取元素
//处理元素
data.run();//处理数据
}
}
void thread_pool_exit()
{
this->flag = 1;//代表线程需要退出了
sq->callAllThread();//唤醒所有消费者消线程,从而令其能够正常退出
}
private:
SafeQue *sq;//线程安全队列
int thread_count;//记录线程数量
//增加标志位,标志线程是否退出
int flag;
};
int main()
{
//创建线程池
ThreadPool pool;
//主线程作为生产线程,向线程池中push数据
int ret = pool.InitThreadPool(2);//创建两个线程
if(ret<0){
return 0;
}
for(int i=0;i<100;i++){
Que_data qd(i, Deal1);
pool.Push(qd);
}
pool.thread_pool_exit();//全部退出
return 0;
}
6.8.3线程池中的线程如何退出
(1)线程是自己退出的,而不是因为进程退出而被迫销毁pthread_exit
(2)队列的元素没有待处理的
为了保证线程安全队列中没有元素,需要在pop的时候,判断其元素个数为0时才可进行,且是根据标志位进行的,此外,在Push数据到安全队列时,也需要判断标志位是否符合退出要求,从而令其不再插入元素到安全队列中,当前需要退出时,还需要先解锁,令后续的线程好开始抢锁,且应该唤醒所有消费者线程,令其开始一个个退出,退出前也需要额外的解锁代码
6.8.4读写锁
读写锁的两种模式:以读模式加锁、以写模式加锁
多个线程,访问临界资源时,都是读临界资源的内容,一定不会产生二义性结果
读写锁适用的场景:大量读,少量写的情况。因为读写锁允许多个线程并行读,但是互斥写(与互斥锁相同)
6.8.4.1接口
int pthread_rwlock_init(pthread_rwlock *rwlock, const pthread_rwlockattr_t *attr)
初始化
pthread_rwlock_t:读写锁的类型
attr:NULL,采用默认属性
销毁:
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock)
加锁:
以读模式加锁:
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock)//阻塞加锁
以写模式加锁:
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);//相当于互斥锁
注:读模式的锁可以共享
解锁:
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock)
eg1:
#include
#include
#include
int main()
{
//读模式+读模式
pthread_rwlock_t rwl;//创建读写锁
pthread_rwlock_init(&rwl, NULL);
pthread_rwlock_rdlock(&rwl);
printf("第一次加锁成功\n");
pthread_rwlock_rdlock(&rwl);
printf("第二次加锁成功\n");
//销毁
pthread_rwlock_destroy(&rwl);
return 0;
}
由此可见读模式的锁可以共享
eg2:若第二次以写模式打开,由于其可更改内容,另一个人读到的内容可能被更改,因此不允许
#include
#include
#include
int main()
{
//读模式+读模式
pthread_rwlock_t rwl;//创建读写锁
pthread_rwlock_init(&rwl, NULL);
pthread_rwlock_rdlock(&rwl);
printf("第一次加锁成功\n");
pthread_rwlock_wrlock(&rwl);//以写模式打开
printf("第二次加锁成功\n");
//销毁
pthread_rwlock_destroy(&rwl);
return 0;
}
读写锁允许读-读并行允许是由于其中有个引用计数,当有已读拿到锁时就会令这个引用计数+1,其记录了当前读写锁有多少线程以读模式获取了读写锁
当有线程以读模式进行加锁,加锁成功,则引用计数++
当以读模式打开读写锁的线程,释放了读写锁后,引用计数--
只有当引用计数为0时,线程才能以写模式打开读写锁
如果读写锁已经以读模式打开,有一个线程想要以写模式打开获取读写锁,则需要等待。若在等待期间,又来了读模式加锁的线程,拿读模式的线程也跟着等待,以免写模式饥饿
由图可见,当读后有写,会陷入等待,若再有读,也会陷入等待
6.8.5单例模式
单例类只能有一个实例
单例类必须自己创建自己的唯一实例
单例类必须给所有其他对象提供这一实例
意图:保证一个类只有一个实例,并提供一个访问它的全局访问点
主要解决:一个全局使用的类频繁的创建与销毁
何时使用:若想控制实例数目,节省系统资源时
如何解决:判断系统是否已经有这个单例,如果有则返回,没有则创建
关键代码:构造函数私有
两种形式:
饿汉模式:程序启动时就创建唯一的实例对象,不需要加锁
懒汉模式:当第一次使用时才创建一个唯一的实例对象,从而实现延迟加载的效果。其在第一次使用单例对象时才完成初始化工作,因此可能存在多线程竞争环境,如果不加锁可能会导致重复构造或构造不完全的问题。
6.8.6乐观锁与悲观锁
悲观锁:针对某个线程访问临界区修改数据的时候,会认为有可能有其他线程并行修改的情况发生,所以在线程修改数据之前就进行加锁,令多个线程互斥访问。悲观锁包括:互斥锁、读写锁,自旋锁
乐观锁:针对某个线程访问临界区修改数据时,乐观的认为只有在该线程在修改,大概率不会存在并行的情况,所以修改数据不加锁,但是修改完毕进行更新的时候,进行判断。如:版本号控制,CAS无锁编程
6.8.7自旋锁
一种悲观锁
自旋锁和互斥锁的区别:
(1)自旋锁加锁时,若加不到锁,线程不会切换(时间片没有到的情况,时间片到了还是会切换)会持续尝试拿锁,直到拿到自旋锁
(2)互斥锁加锁时,加不到锁线程会自动切换,进入睡眠,当其他线程释放互斥锁后,被唤醒。再切换回来进行抢锁。
(3)自旋锁优点:因为自旋锁不会引起调用者睡眠,因此自旋锁效率高于互斥锁
(4)自旋锁缺点:自旋锁一直占用CPU,在未获得锁的情况下,一直运行,若不能在很短时间内获得锁,会使CPU效率降低
(5)适用于临界区代码较短时,自旋锁效率较高,因为线程不用来回切换
(6)当临界区执行时间较长,自旋锁不适用,因为拿不到锁会占用CPU一直抢锁
你可能感兴趣的:(Linux学习,笔记)