一篇朴实无华的Unix系统编程(Unix环境高级编程)学习笔记。
一、计算
C语言的数据表示与处理
计算
计算的两个部分
-
具体的的操作 -
操作的对象
在计算机内部,这两个部分本质都是0和1,最直观的体现就是前缀表达式:
01100000 xxxx xxxx 加 ADD
00100xxx xxxx xxxx 移 MOV
01110000 xxxx xxxx 跳转 JMP
10000000 xxxx xxxx 函数调用 CALL
C语言的基本运算操作
- 声明(写符号表后在内存分配存储空间)
- 取址 (取到内存或者寄存器中)
- 取值 (取到内存或者寄存器中)
- 计算 (CPU的逻辑运算单元执行)
- 赋值 (一般是赋值到内存中,异步写入磁盘,除非手动flash)
- 跳转
- 函数调用
内存表和符号表
C语言在执行上述的基本运算操作时主要的依据是两张表:
- 内存表M (本质是数据的存储空间)
- 符号表 ST(三元组(变量名,数据类型,起始地址)
【注意】
- 函数名也作为一个变量名存储在符号表当中,有自己的数据类型。
其中一个很重要的原因在于:函数根据不同的数据类型可能存在重载,需要类型来区别。
-
另外对于结构的定义和对函数的定义是不一样的: ① 结构定义:没有定义变量,定义的是类型; ② 函数定义:根据所定义的函数的返回值类型,参数类型写入符号表。
计算机是如何将0和1对应到内存表和符号表上做具体操作的呢?
主要是通过下面两类语言作为桥梁:
- 把操作从0和1转变为符号 —— 汇编语言
- 把操作数的地址(也是0和1)转变为符号名 —— 高级语言
- 高级语言又主要有两种类型:
- 解释型(Java、Python):慢、跨平台通用性(通过虚拟机运行),写一条执行一条;
- 编译型(C、C++):快、跨平台库可能不同(有冲突),编译后整体执行。
类型转换
【例】看一个前缀表达式:
01100000 01100000 01100000
? ADD 96 96
计算的结果为11000000,如果是无符号数则是192,若翻译为有符号数则是-64,所以存在类型的问题;
通过强制类型转换可以避免构造过多的加法器;
C程序中的数据类型包括:
- 基础类型 : char int unsign long float
- 导出类型 :数组 指针 函数
- 自定义类型:结构体
在不同的系统上,这些类型占据的字节长度是不同的,在32 位的系统上:
数据类型 | 所占地址大小(Bytes) |
---|
char | 1 | short | 2 | int | 4 | long | 4 | float | 4 | double | 8 | long long | 8 |
数据类型的转换优先级主要是按从小到大排序:
double ←— float 高 ↑ long ↑ unsigned ↑ int ←— char,short 低
函数类型的分析
(一)参数函数(把函数作为参数)
(1)分析Int (*p)(char) 类型*
假设 *p为x,则 *p ~ Type(x) = fun(char,int)
则p ~ *Type(x) = * fun(char,int)
说明p的类型是一个指针,且&p在内存表中只占一个字(32位机子就是2^32即4Bytes)的大小
(2) 分析Int *q(char) 类型
同理可得q ~ fun(char,int*)
说明&q为始的在内存的代码段中占的大小即汇编后函数的大小
(二)返回值函数
(3)分析函数类型:int wfa (int f(char))
整式的参数是一个函数:(int f(char)),返回值是int类型
故函数wfa的类型为:wfa ~ fun(Type(f),int)
而Type(f)= fun(char,int),即:f ~ fun(char,int)
故函数wfa的类型为:wfa ~ fun(fun(char,int),int)
(4)void signal 函数类型分析
? Void ( *signal ( int signo, void( *func ) ( int ) ) ) ( int )的类型分析
? 设x~fun(int,void),则:( *signal ( int signo, void( *func ) ( int ) ) ) = x
? 故( *signal ( int signo, void( *func ) ( int ) ) ) 具有 x 的类型,做*的转移后:
? 即 signal ( int signo, void( *func ) ( int ) ) ~ * Type(x)= * fun(int,void)
? 再取出void(*func)(int)分析,令*func为y,则:
? *func = y ~ fun(int,void)==》func ~ *Type(y)= *fun(int,void)
? 那么由signal ( int signo, void( *func ) ( int ) ) ~ * Type(x)可知:
? Signal ~ fun(int,* fun(int,void),* fun(int,void))
? 至此成功分析出函数signal 的类型
指令
C程序中每一个语句都是对符号表和内存表的处理。
C语言中的函数是对系列指令进行封装
复合指令
即语句的集合。
1. 顺序指令
2. 分支跳转指令 —— if…else
注意C语言的if里不允许赋值,所以ST内容在exp后是不变的,但是内存表M会变。
3. 循环跳转语句 —— while
4. 无条件/直接跳转语句 —— goto
句法
C程序本质上是字符序列,执行程序需要将字符序列转化成语法树;
定义是由第一个定义和后续定义构成的,通过递归实现上下文无关文法。
函数
什么是函数?
从集合A到集合B的映射 f(A)->B
【例】Int f( char a ) : f是一个变量—>类型(集合),该函数就类型char –> int的映射。
使用函数可以方便代码的复用,减小人工的工作量,但是从某些方面来看并不会让程序的执行变的更为高效。
主要是因为函数调用会产生额外的运算操作(取址,取值,传参,以及有可能的上下文切换等)。
回调函数的一个点
我们可以把C中的struct(结构体)类比C++中的class(类):
通常情况下:
- class对“静态”的变量和”动态“的操作都做了封装,但struct通常是对变量/类型的封装。
- struct也可以封装操作(函数),但是要通过回调函数(指针函数)的形式实现;通常class封装的操作默认为public,而struct通过这种方式封装的操作为private。
函数激活(Activation Record)
假如g是函数名,c是函数里的一个变量,那么c存储在什么位置呢?
函数激活定义
在栈中函数的调用叫做函数激活,也称为栈帧,用于单独存储一个函数调用时所需的各种信息。
【例】函数g的每次执行相当于激活。
函数激活存储内容
-
参数:实参 -
类型: -
局部数据or临时变量:函数中的变量存储位置 -
控制链:指向调用该函数的函数激活 -
返回值:给上一个函数激活使用 -
访问链:指向上一个非本函数自身的函数激活 -
状态记录:用于记录进程被等停时的状态
调用函数时:
-
放入代码区、执行main、分配栈中的函数激活; -
把实参(从上一个函数激活、全局变量等)放入实参表;
-
计算结果并存入返回值; -
根据控制链找到相应的语句将值写回(若用 * 或 &则会写回改变后的值?); -
释放函数激活(弹出栈帧);
二、并发
目标:理解计算机如何管理资源 —— 通过操作系统。
这里的资源指的是:
- 外部资源:硬件如磁盘等非必要资源。
- 内部资源:内存、cpu(实际考虑的是时间片)。
基本的操作术语理解:
异步:布置出去,不等结果
并发:同一时间框架下,(时空)资源的分享机制(宏并微串)
通信:将信息从一个实体传递给另一实体(进程、设备、主机)
并行: 同时进行
实现同时处理多个时间或任务、满足多任务的时间需求。
多道程序设计和分时
Unix分时:存储的保存和加载
- 多道程序设计:指有多个进程准备好要执行,操作系统挑选一个已经准备好的进程来执行。当那个进程需要等待资源(如一次按键或一次磁盘访问)时,操作系统保存从停止处恢复此进程所需的所有信息,并选择另一个准备好的进程执行。
- 多道程序设计同时还进行了分时。分时时给人造成尽管可能只有一个物理CPU,但是却有几个进程同时运行的错觉(操作系统在进程间快速切换——并发)。
中断(interrupt)
- CPU 微处理器有一个中断信号位, 在每个CPU时钟周期的末尾, CPU会去检测那个中断信号位是否有中断信号到达, 如果有, 则会根据中断优先级决定是否要暂停当前执行的指令, 转而去执行处理中断的指令。 (其实就是 CPU 层级的 while 轮询)‘
时钟中断( Clock Interrupt )
- 一个硬件时钟会每隔一段(很短)的时间就产生一个中断信号(实际上是电信号)发送给 CPU,CPU 在响应这个中断时, 就会去执行操作系统内核的指令, 继而将 CPU 的控制权转移给了操作系统内核,使CPU转而执行定时器中断服务例程 , 由操作系统内核决定下一个要被执行的指令。
系统调用(system call)
进程切换(上下文切换)
- 当一个程序正在执行的过程中, 中断(interrupt) 或 系统调用(system call) 发生可以使得 CPU 的控制权会从当前进程转移到操作系统内核中设备驱动程序(devicec driver)的操作系统例程(即内核进程)。
- 操作系统内核负责保存进程 i 在 CPU 中的上下文(程序计数器, 寄存器)到 PCBi (操作系统分配给进程的一个内存块)中。
- 切换页表,从PCB j取出进程 j 的CPU 上下文,切换地址空间, 将 CPU 控制权转移给进程 j , 开始执行进程 j 的指令。
分时的实现
-
将程序的指令序列分块 -
划分与程序对应的独立存储区域 -
公共空间的使用 :
? 3.1:保存与加载
? 3.2:PC计数器
? 3.3:通用寄存器
- 硬件支持
注意:引入3是为了高效,引入4是为了状态记录
应用层并发
- 中断(终止当前指令)
- 在常规机器层程序中断指令的执行是处理器指令周期的结果。在处理器指令周期的正常执行过程中,处理器从程序计数器中检索出一个地址,并执行该地址上的指令。在常规机器层出现并发,是因为外围设备产生了中断(一种电信号)。
- 中断对于系统中执行的程序是异步的(不总在相同的点,但都会返回正确结果),但是错误事件是同步的。
- 设备驱动程序(devicec driver)的操作系统例程(即内核进程)通常用来处理外围设备的产生的中断。然后这些驱动程序会通过信号这样的软件机制来通知相关进程事件已经发生;
- 操作系统也用中断实现分时:定时器在一段指定时间间隔后产生中断;定时器到时后,它就产生一个中断,使CPU转而执行定时器中断服务例程 —— 实际上就是时间片的轮转(可以联想一下基于时间片轮转下操作系统对进程的调度过程)。
慢速系统调用
可能会使进程永久阻塞的一类。
-
信号:软件层面的中断
-
IO设备 —— 也需要使用分时的机制
非阻塞和异步的差别:
(1) 异步布置出去一定要做,但是不关注完成时刻
(2) 但是非阻塞是在请求时刻如果不做那就永远不再做
- 并发:
- 指相同时间帧内对资源的共享(微观串行,宏观并行);
- 即当两个程序在同一个系统上执行、且它们的执行在时间上产生交错时,它们就在共享处理器的资源。
三、程序、进程和线程
什么是程序?
程序是用来完成特定任务的指令序列。
注意:集合是无序的,而序列是有序的
从实际上(就拿c语言)来说,一个.c文件经理预处理、编译、汇编、甚至链接这一系列过程中产生的文件,在广义上都还是属于程序的范畴。 上面说的“过程”简单示意为: name.c —> name.o —> link成 a.out(可执行代码)
关于程序的编译过程,推荐参考这篇文章:Linux下详解gcc编译过程(含代码示例)&& gcc使用教程
什么是进程?
进程可以简洁地理解为“运行的程序”,有的解释为程序的一次执行。
更深入一些的理解:
- 当操作系统向内核数据结构中添加了适当信息,并为运行程序代码分配了必要的资源后,程序就编程了进程;
- 进程用于地址空间(它可以访问的内存)和至少一个被称为“线程(thread)”的控制流。
进程的状态有哪些?其转换关系是怎么样的?
① 创建状态:进程在创建时需要申请一个空白PCB,向其中填写控制和管理进程的信息,完成资源分配。如果创建工作无法完成,比如资源无法满足,就无法被调度运行,把此时进程所处状态称为创建状态。
② 就绪状态:进程已经准备好,已分配到所需资源,只要分配到CPU就能够立即运行。
③ 执行状态:进程处于就绪状态被调度后,进程进入执行状态。
④ 阻塞状态:正在执行的进程由于某些事件(I/O请求,申请缓存区失败)而暂时无法运行,进程受到阻塞。在满足请求时进入就绪状态等待系统调用。
⑤ 终止状态:进程结束,或出现错误(非正常终止),或被系统终止(正常终止),进入终止状态。无法再执行。
其实还有7态图,加入了挂起的概念,涉及换页,这里不展开了。
进程什么时候开始产生/运行?
进程以执行一个指令序列的控制流开始。
更深入一些的理解:
进程从单一控制流开始,从第一个激活记录(Activation Record ,可以简单理解为函数栈帧)产生的时候(大多数情况从main开始),执行指令序列。
拓展了解:
- cpu读取一条指令后会对PC的值做增量运算,遇到分支会做进一步修改;
- 可能有多个进程驻留在内存中并发的执行,他们基本上相互独立;
- 进程若要相互通信或合作,则必显示地通过文件系统、管道、共享内存或网络这样的操作系统结构来进行交互;
- 执行从 一个进程切到另一个进程的点被称为上下文切换;
进程拥有地址空间(它可以访问的内存)和至少一个被称为线程(Thread)的控制流;
什么是线程?
执行线程: 程序执行时,由进程程序计数器PC的值来决定下面该执行哪一条进程指令,得到的指令流被称为执行线程(thread of execution),它可以用此程序代码执行期间为程序计数器指定的零地址序列来表示。
什么是线程?
线程: 线程代表了进程内执行线程的一种抽象的类型,有独立的执行栈、程序计数器值、寄存器组和状态。
基于进程资源实现多操作并发,是系统调度的最小单位。
是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在Unix System V及SunOS中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。
上下文切换: 执行线程中的指令序列对于进程来说就像是一条不间断的地址流。但从处理器CPU的角度来看,来自不同进程的执行线程是混在一起的;执行从一个进程切换到另一个进程的点被称作上下文切换。
进程模型的自然拓展允许多个线程在同一个进程内执行。(同一进程)使用多个线程可以避免上下文切换,并允许共享代码和数据(即共享地址),籍此改善性能。
进程切换与线程切换
-
进程切换与线程切换的一个最主要区别就在于进程切换涉及到虚拟地址空间的切换而线程切换则不会。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。 -
同一进程内用户级线程切换不需要触发内核态,不同进程间的用户级线程切换则引起进程切换,所以必定在内核态。 -
内核态线程切换肯定是要内核调度切换,但是用户态线程切换不需要内核参与。
用户空间和内核空间
操作系统为了支持多个应用同时运行,需要保证不同进程之间相对独立(一个进程的崩溃不会影响其他的进程 , 恶意进程不能直接读取和修改其他进程运行时的代码和数据) 。 因此操作系统内核进程需要拥有高于普通进程的权限, 以此来调度和管理用户的应用程序。
于是内存空间被划分为两部分,一部分为内核空间,一部分为用户空间,内核空间存储的代码和数据具有更高级别的权限。内存访问的相关硬件在程序执行期间会进行访问控制( Access Control),使得用户空间的程序不能直接读写内核空间的内存。
进程列表PL
为了管理与调度,操作系统会维护一张进程列表(简称为PL)。 以Linux来说,该进程列表的表项是一个PCB块。
PCB(Process Control Block,程序控制块)是一种数据结构,包含进程的相关信息。系统利用PCB描述进程的情况和活动过程,并控制管理进程。 一定要注意的是:PCB块是由内核来管理和调度的,不属于用户空间! 这个理解很重要!是理解进程实体的关键!
PCB 进程控制块
进程控制块(PCB 即 Process Control Block)是进程状态信息的集合,用来描述进程和用于进程的管理和调度,用PCB可以区分不同的进程。
PCB主要包含的信息有
属性 | 说明 |
---|
标识符PID(identifier) | 唯一标识进程 | 状态(state) | 进程的当前状态(运行/就绪/等待) | 优先级(priority) | 相对于其他进程的优先级别(做内存调度时使用) | 程序计数器(PC = Program Counter) | 即将被执行的下一条程序指令的地址 | 内存指针(memory pointers) | 包括指向程序代码、相关数据和共享内存的指针 | 上下文数据(context data) | 进程被中断时处理器寄存器中的数据,可以用于进程的恢复 | 记帐信息(accounting information) | 包括占用处理器时间、时钟数总和、时间限制、账号等,比如在进程处于退出态时可以取出PCB的记账信息来做性能分析 | I/O状态信息(I/O status information) | 包括显式I/O请求、分配给进程的I/O设备、被解除使用的文件列表等 |
进程实体
学习过操作系统的同学肯定都背过这样的标准答案: “ 进程实体由代码段、数据段和PCB组成 ”;
这句标答确实没有毛病,但是对于学习Unix系统编程/高级Unix环境编程的同学来说这样的理解肯定是不够的,我们还要从内核空间和用户空间的角度对进程实体做区分。
首先我们来看看进程列表PL是怎么和进程实体挂上钩的
操作系统维护的进程列表的表项为PCB,而每个PCB对应一个进程实体。
回顾一下,我们在操作系统中记忆的标准答案——“进程实体由数据段、代码段、PCB构成”,这里所说的数据段和代码段其实指的是 “进程用户地址空间”(VM)。
用户地址空间(VM)
属性 | 说明 |
---|
环境变量 | 用于指定操作系统运行环境的一些参数 | 栈 (Stack) | 存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈 | 堆 (Heap) | 存储动态内存分配,需要程序员手工分配,手工释放。注意它与数据结构中的堆是两回事,分配方式类似于链表 | 未初始化全局变量(BSS) | 在程序运行初未对变量进行初始化的数据 | 初始化全局变量(Data) | 在程序运行初已经对变量进行初始化的数据 | 程序段(Text) | 程序代码在内存中的映射,存放函数体的二进制代码(编译后的代码) |
拓展理解性知识:
程序加载之后,可执行程序看起来占据了一个连续的内存块,这个连续的内存块被称为程序映像(program image)。程序映像有几个不同的分区。程序文本或代码显示在内存低端地址中。在映像中已初始化和未初始化的静态变员也有自己的分区。其他的分区包括堆、栈和环境。
活动记录(activation record) 活动记录是在进程栈顶端分配的一个内存块,用来 装载调用过程中函数的执行上下文 。每次函数调用都在栈上创建一个新的活动记录。假如嵌套的函数调用按照后调用先返回的次序工作,那么,函数返回时会将活动记录从栈中删除。 活动记录包括返回地址、参数(参数值从相应的命令行参数小拷贝而来)、状态信息和调用时某些CPU寄存器值的拷贝。进程从记录表示的调用中返回时,要恢复寄存器的值。活动记录中还包括含数执行时在其内部分配的自动变量。活动记录的特定格式取决于硬件和编程语言。
除了静态变量和自动变量之外,程序映像中还包括了argc和argv占用的空间以及 malloc分配的空间。 malloc含数族在一个被称为堆(heap)的空闲内存池中分配存储空间。在堆上分配的存储空间一直存在,直到它被释放或程序退出为止。 如果一个函数调用了 malloc,那么在这个函数返问之后,存储空间仍保持已分配状态。除非程序有一个在函数返叫之后仍然可以访间的指向该存储空间的指针,否则,返回后的程序就不能访问它。
在声明时没有显式初始化的静态变量在运行时被初始化为0。 注意,在程序映像中, 已初始化的静态变量和未初始化的静态变量占据不同的分区。 通常, 已初始化的静态变量是磁盘上可执行模块的一部分,而未初始化的静态变量则不是。 当然,自动变量不是可执行模块的一部分,因为只有当定义它们的程序块被调用时,它们才被分配。除非程序显式地对自动变运进行初始化,否则,它们的初始值是不确定的。
总结进程实体
进程实体包含:
(1) 进程组,会话
(2) 进程凭证(权限 —— 各种id)
(3) 文件描述符
(4) 资源限制
(5) Syscall
(6) 信号相关内容
(7) VM(用户空间)
(8) 线程列表(链表,每个链表单元包括独立的线程栈、相关信息(PC计数器、寄存器等)、调度);
线程可共享的进程资源
【注意】
① 线程可以共享进程的大多数数据or资源,但是不能通过变量名访问进程VM空间中栈的变量(是局部变量),但是或许可以通过访问局部变量的地址用指针来访问(不同操作系统规定不同)。
② 除了VM中的栈,其他的包括进程组/会话和进程凭证、信号、fdlist、syscall都可以共享
僵尸进程与孤儿进程
将僵尸/孤儿进程进程组中的父进程信息改为自己,让它们将结束信息向自己汇报,使它们得以释放资源(VM等)。
程序是什么?进程是什么?线程是什么?进程与线程的区别与联系?
① 程序:
程序是用来完成特定任务的指令序列,是指令、数据及其组织形式的描述。
② 进程:
进程是一段程序的执行过程。
③ 线程:
是操作系统能够进行运算调度的最小单位。
④ 程序与进程的联系:
当操作系统向内核的数据结构中添加了适当信息并为程序分配了必要资源后,程序就变成了进程。
⑤ 进程与线程的联系:
(1)进程的执行以一个指令序列的控制流开始,进程拥有地址空间和至少一个被称为线程的控制流。
(2)一条线程指的是进程中一个单一顺序的控制流,线程被包含在进程之中,是进程中的实际运作单位。
(3)一个进程中可以并发多个线程,每条线程并行执行不同的任务。
(4)进程实体中含线程列表,Linux中线程列表实际上是一个链表,每个链表单元包括独立的线程栈、计数器、寄存器、TCB块和调度器,各线程独立调度,同一进程下的线程共享进程的地址空间。
【注意】
线程可以共享进程的大多数数据or资源,但是不能通过变量名访问进程VM空间中栈的变量(是局部变量),但是或许可以通过访问局部变量的地址用指针来访问(不同操作系统规定不同)。
除了VM中的栈,其他的包括进程组/会话和进程凭证、信号、fdlist、syscall、信号掩码等都可以共享。
⑥ 进程与线程的区别:
(1)进程是资源分配的基本单位,线程是系统调度的实际基本单位。
(2) 每个进程都有自己的虚拟地址空间,而进程内的所有线程共享进程的虚拟地址空间。
(3)进程切换涉及到虚拟地址空间的切换,而线程切换则不会,故进程切换的开销大于线程。
(4)进程的切换需要内核调度切换,但线程的切换分两种情况:基于真实系统调用的内核级线程需要内核调度切换,但基于非阻塞系统调用编写的线程库实现的用户态线程切换不需要内核参与。
Unix中的进程
进程ID(PID)
每个进程都有一个整型的标识符进行标识 —— 进程号:PID(Process ID):用于给操作系统识别ID;
- PID从1开始(PID ==1 的进程是init进程);
- PID的序列 —— 进程列表(PL)是一个指针数组,每个单元都指向一个进程实体(PE);
- fork:由一个进程产生一个新进程,原有进程则叫做父进程,新进程则为子进程;
子进程还会存储父进程的PID(即PPID——Parent PID),联系僵尸进程和孤儿进程; - init进程的PPID是自己(即1);
- getpid、getppid函数;(pid_t是一个无符号整型的数据类型)
- 无法随时查看子进程pid,但是在fork的时候通过父进程调用fork()的返回值是可以看到的
- PID和PPID功能:用于操作系统对进程的管理与操作;(功能)
用户ID (UID)
- 操作系统给用户提供的整形标识
- Root用户 —— uid == 0
- Uid的作用:对用户进行访问控制的依据(权限)
- 用户登录的时候把身份属性传递给(会话首)进程,进程保留该属性,进行访问控制;
- 一个进程的开启要有可执行文件,那么就有创建者(owner)
- 一个进程带有使用者(uid)的信息和所有者(owner)的信息,但是当涉及到操作权限的判断的时候基于进程的有效用户:EUID和EGID
fork()
fork:创建新进程:通过复制当前进程来完成任务,返回值是pid;
如果父进程中用变量存储了fork的返回值,那么fork后在父进程的栈中fork函数的函数激活的返回值是子进程的pid,而在子进程中则是0;
fork()的返回值:
-
在父进程中,fork返回新创建子进程的进程ID; -
在子进程中,fork返回0; -
如果出现错误,fork返回一个负值;
fork()函数通过系统调用创建一个与原来进程(父进程)几乎完全相同的进程。
- 子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。
注意,子进程持有的是上述存储空间的“副本”,这意味着父子进程不共享这些存储空间。linux将复制父进程的地址空间内容给子进程,因此,子进程有了独立的地址空间。也就是这两个进程做完全相同的事。
fork出来的进程与父进程的不同之处:
所有与PID静态相关的东西基本都会继承,动态相关的东西基本都会更新;
PID、运行时间、信号、锁(父进程上的锁、屏蔽的信号不应该带到子进程中)。
exec函数族
exec函数族提供了一种(不需要额外分配资源)加载程序的操作。
在fork后的子进程中使用exec函数族,可以装入和运行其它程序(子进程替换原有进程,和父进程做不同的事)。
相当于是直接对现有某个PE的VM的改动(堆栈全部清空等操作),PE中其他部分基本不动(除了文件描述符列表被清空和信号可能会改动);
exec函数族可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段。在执行完后,原调用进程的内容除了进程号外,其它全部被新程序的内容替换了。另外,这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行脚本文件。
exec函数族一般规律:
exec函数一旦调用成功,即执行新的程序,不返回。
只有失败才返回,错误值-1,所以通常我们直接在exec函数调用后直接调用perror(),和exit(),无需if判断。
【注意】事实上,只有execve是真正的系统调用,其他5个函数最终都调用execve,是库函数,所以execve在man手册第二节,其它函数在man手册第3节。
进程的内存使用和(操作系统)管理情况
在进程产生时操作系统会为其分配相应的进程实体,包含内核管理的PCB块和用户地址空间VM;
总的来说,一个进程实体包含以下内容:
(1) 进程组,会话
(2) 进程凭证(权限 —— 各种id)
(3) 文件描述符
(4) 资源限制
(5) Syscall
(6) 信号相关内容
(7) VM(用户地址空间)
(8) 线程列表(线程独立栈、计数器、寄存器、调度器);
而用户地址空间VM包括以下内容:
环境变量 | 用于指定操作系统运行环境的一些参数 |
---|
栈 (Stack) | 存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。含活动记录(栈帧)。 | 堆 (Heap) | 存储动态内存分配,需要程序员手工分配,手工释放。注意它与数据结构中的堆是两回事,分配方式类似于链表 | 未初始化全局变量(BSS) | 在程序运行初未对变量进行初始化的数据 | 初始化全局变量(Data) | 在程序运行初已经对变量进行初始化的数据 | 程序段(Text) | 程序代码在内存中的映射,存放函数体的二进制代码(编译后的代码) |
为了管理与调度,操作系统会维护一张进程列表(PL),以Linux来说,该进程列表的表项是PCB块,用于在进程调度时记录和还原进程当前工作相关信息。
此外,基于进程列表,操作系统根据进程的被标记的状态划分出两个个队列 —— 阻塞队列、就绪队列在此基础上运行进程调度算法(例如轮转法、短进程优先、FIFO算法等),实现进程并发。
四、Unix I/O
终端设备
指计算机系统能够访问的硬件。
系统IO
系统IO —— 指Unix系统的IO
一次open的成功返回会创建一个文件实体(fork不涉及open操作,涉及的是dup)。
不管多少个进程的fd列表打开了多少个进程实体,操作系统为了管理,将所有进程打开的文件实体(不论是否重复)都作为表项(记录)维护在文件系统的系统(打开)文件表中。
注意在fork子进程的时候,文件描述符列表中的指针也被复制。
关于上图中的知识体系推荐这篇博客:用图片带你串起进程列表、进程控制块、inode节点、文件描述符列表、文件实体、文件系统等知识_狱典司的博客-CSDN博客
标准IO
标准IO —— C标准
打开一个文件之后,若要对文件进行多次读写:
在堆(内存)中设置缓存用于存储文件的数据(Block的内容),便于提高读写效率(fopen函数)
【因为在系统IO中文件数据是保存在磁盘中的,多次读写需要多次磁盘IO效率低】
【例】fopen函数将文件信息读到缓冲中。
重定向
重定向:把PE的FDL中文件描述符指向别的文件实体。
Dup2(fd1,fd2)会把fd1关掉,(若是fd2已存在则把原fd2关掉)。
fcntl函数可以改变fd的状态(比如阻塞非阻塞)。
编程练习: Unix上用C程序实现pipe管道命令“ | “(pipe,fork,dup2,close,execl,重定向)_狱典司的博客-CSDN博客
进程打开文件的内存使用
① 进程根据其进程实体中文件描述符列表使用其打开的文件,所谓文件描述符列表实际上是files_struct 结构中的struct file *fd[NR_open]数组,文件描述符号则是该数组的下标,每个数组单元是一个指向文件实体(文件结构,即struct file)的指针,而文件系统维护一张由文件实体(struct file)为链表单元组成的双向链表,即系统打开文件表。
② bash子进程默认在文件描述符0,1,2上打开标准输入、标准输出、标准错误输出。
③ fork() 出来的子进程继承父进程的文件列表,双方的文件描述符列表里的指针指向的是同一个文件实体。
④ 使用dup2做重定向时,文件描述符列表里被复制的fd与复制出的fd指向指向同一个文件的同一个文件实体。
⑤ 系统IO下每次open的成功返回都将创建一个文件实体,IO的文件数据是无缓冲的,即保存在磁盘上。
⑤标准IO下在堆(内存)中设置缓存用于存储文件的数据(Block的内容),便于提高读写效率(fopen函数)。
五、文件和目录
Unix文件系统
逻辑概念:
- 操作系统将物理磁盘组织成文件系统的形式,以提供对文件中实际字节的高级逻辑访问。
- 是文件与属性的集合,实现了对文件及属性的管理。
文件系统中的同步案例
文件中的clean和dirty块涉及到同步的问题 — 是主计算机和边缘设备(磁盘、打印机等)的同步。
广义文件的七种类型:
① 常规文件
【特点】Linux中最多的一种文件类型, 包括纯文本文件(ASCII);二进制文件(binary);数据格式的文件(data);各种压缩文件。默认会把可以且成功写入的内容保存。
② 目录文件
【特点】其block中存储的不是文件数据而是目录树中其子节点的目录项,包括文件名和inode号。
③ 管道
【特点】是匿名管道,由进程内部的文件描述符指向,非其子进程的外部进程无法使用该管道,且在没有进程打开管道的时候PIPE就消失了;写入的内容随读出而抹除。
④ FIFO(命名管道)
【特点】是一种特殊文件,所有的进程都可以通过其地址(注意不单是文件名)来对FIFO进行访问,且其不会在无进程调用时消失。写入的内容随读出而抹除。
⑤ 字符特殊文件(一个char(字符)的大小为一个字节,通过字节交互)
【特点】串行端口的接口设备,例如键盘、鼠标等等。
⑥ 块特殊文件(收集到一定多字符再处理)
【特点】就是存储数据以供系统存取的接口设备,如硬盘。
包含被编号的块,每一块都可以独立地读取或者写入。而且可以定位于任何块,并且开始读出或写入。
⑦ 链接(软链接/符号链接)
【特点】是一类特殊的文件, 其包含有一条以绝对路径或者相对路径的形式指向其它文件或者目录的引用。
与属于普通文件的硬链接相比,软连接拥有独立的inode节点。
⑧ socket(不在ls可以列出的文件类别范围内)
【特点】由文件描述符指向的可用于(设备间)进程通信的套接字,借助内核分配给的写缓冲和读缓冲实现。
硬链接和软连接
- 硬链接:是指在两个或多个目录的block中都有同个文件的(同个)inode的信息,实质上是一个指针,指向文件索引节点,系统并不为它重新分配inode。
注意文件和inode是一对一的关系。区别于文件和文件实体可以是1对多的关系。
- 软连接(符号链接):是单独的文件,保存的是绝对路径,系统会为它重新分配inode。
一个分区是一个文件系统(类型),硬链接无法跨分区(inode不一致),软连接可以跨分区存放(比如C盘可以用软连接索引D盘的内容)。
【注意】上述测试中可以看到硬链接属于普通文件,前缀是【-】,而软链接属于单独的文件类型——链接文件,前缀为【l】。
文件存储
-
Boot区 -
Superblock区 -
扇区
-
复制的superblock:介绍分区中该扇区信息 -
Inode Map -
Block Map -
I节点区 -
Block区
注意:
I节点结构(128B)
注意:文件的名字真实存储并不在文件的i节点本身,而是在其目录的block中!
-
文件编号(实际上是inode的编号,有可能是数组下标,待考证) -
类型 -
权限(所有者的UID和GID) -
文件长度 -
Block 计数 -
最近的时间信息(创建、访问、修改) -
指针(直接指针、单级间接指针、二级间接指针、三级间接指针)指向Block -
链接计数(存储该文件的目录数)
【注意】文件系统对于管道缓存的大小、文件名长度、文件内容长度都有所限制。
-
Block中可能不存真正的文件数据,有的可能存着其他文件或目录的i节点的编号和文件名(当该block的i节点对应文件为目录)—— 称为目录项 -
注意:文件的名字真实存储并不在文件的i节点本身,而是在其目录的block中! -
(这种类型的block存储文件名和对应的i节点号即文件编号)
【考题】
文件系统怎么对一个文件进行增删改?
Key:联系inode和block
- 借助系统根目录或者系统记录的当前目录或上一级目录找到搜索的起始inode节点
- 若inode是一个目录,则去inode中指向的block里检索搜索路径的下一级文件名和文件编号
- 根据匹配成功的文件名对应的文件编号去检索对应的inode节点
- 循环步骤2-3直到找到目标文件为止。
注意:若是要删除文件,并不是直接清空其所有的block,而是根据inode节点中的信息将inodeMap和blockMap置0,待下次新文件覆盖写入。
文件权限
一个进程带有使用者(uid)的信息和所有者(owner)的信息,但是当涉及到操作权限的判断的时候基于进程的有效用户:EUID和EGID
st_mode存储(指定):文件权限信息、文件类型
注意:stat对于用户只能访问但是不能修改,修改的工作是操作系统的。
stat函数
获取文件属性,(从inode结构体中获取)。
stat/lstat 函数:
int stat(const char *path, struct stat *buf);
参数:
path: 文件路径
buf:(传出参数) 存放文件属性,inode结构体指针。
返回值:
成功: 0
失败: -1 errno
获取文件大小: buf.st_size
获取文件类型: buf.st_mode
获取文件权限: buf.st_mode
符号穿透:stat会。lstat不会。
六、Unix 特殊文件
管道(匿名管道/pipe)
函数原型:int pipe( int filedes[2] )
pipe函数创建了一个通信缓存区,程序可以用通过参数中的文件描述符数组(filedes[2])来访问这个缓冲区。filedes[0]作为写入文件描述符,filedes[1]作为读出描述符并按照先进先出的顺序从通信缓冲区中取出数据。
匿名管道没有永久的名字,系统只能通过他的两个文件描述符(参数fd[2])来访问它。
因此,管道只能由创建它的进程或fork()的时候继承了该进程的文件描述符列表的子进程使用,单一进程使用匿名管道意义并不大。
匿名管道与文件指针
【注意】
①函数的参数中fildes[2]分别表示IO的文件描述符,分别指向不同的文件实体,在文件打开状态中记录谁读谁写;这两个打开的文件实体都指向同一个v节点(同一个文件);
② 即,针对匿名管道pipe,就在同个文件的存储范围内进行(半双工)IO操作,没有额外的缓存空间类似于(buf之类的变量)。
匿名管道的常规使用(建立全双工通信通道)
① 父进程fork出子进程时,子进程可以监听对应文件实体实现进程间通信,但这种通信存在问题 —— 这是一种半双工 通信,为实现可靠通信,可双向通信,但同一时刻只能单端做一个操作(即读或写),防止自己写入的数据被自己读走。
② 建立两个单工管道,即对于单个管道(子)进程和(父)进程分别关闭一个读fd和一个写fd,这样单个进程的读/写分工是明确的,实现两个进程的全双工通信(可双向通信、可同时工作),可以保证单一进程写入单一管道的内容不会被自己读取而不被对端读取。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WxgwkvYw-1641561707025)(C:\Users\Sean\AppData\Roaming\Typora\typora-user-images\image-20220103135255042.png)]
伪代码:
int pid = fork();
int fd[2];
pipe(fd);
if (pid > 0) close(fd[1]);
else close(fd[0]);
匿名管道上调用read
进程在匿名管道上调用read时:
①如果管道非空,read就会立刻返回实际读到的字节数;
②如果管道是空的,那么:
? (1)只要有进程为写操作打开管道,read就会一直阻塞到某些内容被写入管道为止;
? (2)如果没有进程为写操作打开管道,read调用就会立即返回0,表示遇到了文件结束EOF。
【注意】这里所描述的情况假定对管道的访问采用的是阻塞型的I/O(由fcntl控制)。
管道的阻塞机制
- POSIX标准中,PIPE_BUF:最小512B,一般用一个块的大小即4096B。
a)多个进程同时对一个管道进行读写——形成了多生产者多消费者模型
- 读一定是原子操作
- 小于PIPE_BUF的写一定是原子操作
- 大于PIPE_BUF的写可能是非原子操作(若采用了锁机制就是强制的"原子"操作,否则写入的内容大于缓存大小,写进程被迫阻塞,将由某一进程读后才能继续写)
b) 实际的规则:
(1) n<=buf O_NONBLOCK(非阻塞)无效,即一定是阻塞(要么写要么不写)
(2) n<=buf O_NONBLOCK(非阻塞)有效,报错
(3) n>buf O_NONBLOCK(非阻塞)无效,可能交替写
(4) n>buf O_NONBLOCK(非阻塞)有效,写满则报错
c) 使用时注意:
(1) 多进程使用管道时,写入长度小于buf(进程实体的资源限制中有PIPE_BUF项)
(2) 当写入长度大于buf的时候:①加锁 ②一 一对应(每个写的进程独占一个管道,写进程对应的这一组管道可以被多个进程读取)
命名管道(FIFO)
没有进程打开管道时管道就消失了。从这个意义上来说,管道是临时的。POSIX用特殊文件来表示FIFO或命名管道(namedpipe),这些特殊文件在所有的进程都将其关闭之后仍然存在。
FIFO像普通文件一样,有名字和访问权限,而且会出现在ls列出的目录列表中。任何一个且有恰当权限的进程都可以访问FIFO。可以在命令解释程序(shell)中执行mkfifo命令,或者从程序中调用mkfifo 的数来创建FIFO。
mkfifo涵数用来创建新的FIFO特殊文件,创建的文件对应于path指定的路径名。参数 mode为新创建的FIFO指定了权限。
函数原型
#include <sys/stat.t>
int makefifo(conat char *path, node_t mode);
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WkBUxRRt-1641561707026)(C:\Users\Sean\AppData\Roaming\Typora\typora-user-images\image-20220103145234211.png)]
PIPE于FIFO的区别:
PIPE是匿名管道,由进程内部的文件描述符指向,非其子进程的外部进程无法使用该管道,且在没有进程打开管道的时候PIPE就消失了;但是FIFO是一种特殊文件,所有的进程都可以通过其地址来对FIFO进行访问,且其不会在无进程调用时消失。
FIFO于文件的区别:
FIFO相当于一个文件,有权限信息,但是区别在于是FIFO(先进先出),写进去的东西读出之后就删掉了(类似于缓存);而文件将默认会把写入的内容保存。
七、信号
更细节的内容可以参考: Unix系统编程 信号部分学习笔记_狱典司的博客-CSDN博客
信号是对事件(小事件)相关进程的软件层面通知;
- 信号提供了一种不需要忙等(busy waiting/轮询)来等待事件发生的方法。
注意:忙等是指连续使用CPU周期来检测事件(通常是一个变量)的发生。
信号:有发出者和接收者(都是进程)→ 进程间通信
类型:int → SIGINT
信号的产生、递送、挂起
接信号的收者要安装信号处理程序(绑定:信号–>操作)。
如果在传递信号时进程执行了“信号处理程序”,那么进程就捕捉到了这个信号。
程序以用户编写的函数(处理程序)名作为参数调用sigaction来安装信号处理程序,也可以用SIG_DFL或SIG_IGN而不用处理程序来调用sigaction函数:
- SIG_DFL表示采用默认的动作
- SIG_IGN表示忽略信号
上面这两种动作都不是在捕捉信号。
信号生成时采取的动作取决于
- 该信号当前使用的信号处理程序。
- 进程信号掩码(process signal mask)。
注意:信号掩码中包含一个当前被阻塞信号的列表。
与信号掩码相关的两个队列
(1)就绪信号队列(2)阻塞信号队列
**【注意】Fork之后信号队列会被清空吗?—— 会,信号掩码会被清空。 **
注意区分阻塞一个信号和忽略一个信号的区别 1.忽略:直接将被忽略的信号丢弃
实现方式:程序通过调用sigaction将信号处理程序设置为SIG_IGN来忽略一个信号。
2.阻塞:信号不会被丢弃;如果一个挂起信号被阻塞了,当进程解除对该信号的阻塞时,信号会被传递出去。
实现方式:程序通过调用sigprocmask来改变它的进程信号掩码来阻塞一个信号。
Kill函数针对pid划分出的四种情况:
- Pid > 0 : 向相应进程发送信号
- Pid = 0 : 向调用程序的进程组成员发送信号
- Pid = -1 : kill向所有它有权发送信息的进程发送信号
- Pid < -1: kill将信号发送到组ID等于| pid |(取绝对值,即进程组的领头进程pid)的进程组中去
【注意】用户只能向其拥有的进程发送信号。
信号绑定——sigaction
区分一下signal和sigaction
Signal只能绑定而不能查看上一个绑定的操作;
而且没有sigaction这个结构体,也就无法做临时的信号屏蔽(限制)。
sigaction函数讲解
函数作用:
(1)绑定信号与该信号相关的动作
(2)检查与某信号绑定的动作
函数原型:
#include<signal.h>
sigaction( int sig,
const struct sigaction *restrict act,
struct sigaction *restrict oact )
参数讲解:
1. sig:待绑定的信号码
2. act:要绑定的动作所在的结构体,是一个指向struct sigaction结构的指针
如果act为NULL,则不改变与sig相绑定的动作(而不是清空sig绑定的动作)
3. oact:用于接收与信号相关的前一个动作,也是一个指向struct sigaction结构的指针
如果oact为NULL,则对sigaction的调用不会返回sig前一个绑定的动作
返回值:
成功返回0,不成功返回-1并设置errno。
struct sigaction{
void (*sa_handler)(int);
sigset_t sa_mask;
int sa_flags;
void(*sa_sigaction)(int, siginfo_t *, void *)
};
【注意】void(*sa_sigaction)(int, siginfo_t *, void *)是更加高级的回调函数用法,其中参数siginfo_t *指向的结构体中会返回信号传递过程中的相关信息——包括信号的发生者等。
【注意】
①POSIX标准中,信号处理程序(sa_handler)就是一个普通的函数,返回void,并有一个int型参数;
②需要注意的是,在绑定函数sigaction的参数中的act并不是真正的信号处理程序!act是一个结构体(struct sigaction),该结构体中的sa_handler才是(常用的)信号处理程序!
sigprocmask——设置信号掩码
进程可以通过阻塞信号暂时地阻止信号的传递。在这个阻塞信号被传递之前,它不会影响进程的行为。
注意:下列函数的两个重要参数
(1) sigset_t *set:是指向信号集的指针
(2) int signo:是信号编号或在signal.h中定义的大写信号名(宏)
造作信号集sigset_t的五个重要函数讲解:
-
int sigaddset ( sigset_t *set, int signo ) 作用:将signo信号加入信号集set -
int sigdelset ( sigset_t *set, int signo ) 作用:将信号signo从信号集set删除 -
int sigemptyset ( sigset_t *set ) 作用:将信号集set清空 -
int sigfillset ( sigset_t *set ) 作用:将所有的信号加入信号集set中 -
int sigismember(const sigset *set, int signo) 作用:报告signo是否在信号集set中 返回值:在则返回1,不在则返回0;
注意:除了sigismember函数,其他函数若成功都返回0,不成功返回-1并设置errno。
函数sigprocmask —— 将信号集设置为阻塞/解除阻塞 函数原型:
#include<signal.h>
int sigprocmask( int how, const sigset_t *restrict set, sigset_t *restrict oset )
参数解析:
- set:被操作的信号集;如果为NULL,则说明不需要任何修改。
- oset:如果不为NULL,sigprocmask将会把修改之前的信号集放在*oset中返回
- how:用于说明sigprocmask修改信号掩码的方式
(1) SIG_BLOCK:向当前被阻塞的信号(信号掩码)中添加一个信号集 (2) SIG_UNBLOCK:从当前被阻塞的信号中删除一个信号集 (3) SIG_SETMASK:将指定的信号集设置为阻塞信号
等待信号—— pause、sigsuspend和sigwait
POSIX中的函数接口pause、sigsuspend、sigwait提供了三种机制: 将进程挂起,直到信号发生为止。
####函数pause
函数pause(void)的调用会将进程挂起,直到传递了一个信号为止。
pause函数的参数是void,我们是否可以用pause使得进程阻塞等待一个我们期望的(指定的)信号?
答案是肯定的。
pause的作用机制如下:
当收到一个信号,若该信号的绑定动作是终止进程,那么pause不返回;若该信号的绑定了其他的动作,那么回调函数在执行完该绑定动作并返回后,pause就会返回,且总是返回-1。
我们可以在pause前设置相应的信号掩码,让我们期待的信号成为信号掩码(信号屏蔽集)的“漏网之鱼”。
【注意】pause函数存在一个重要的缺陷,也是一个核心问题:
函数sigsuspend
sigsuspend(sigset_t *)
对于pause的缺陷,解决的思路是: 必须再解除对信号的阻塞之后立即启动pause,这两个操作的过程应该被合并成一个原子操作。 sigsuspend函数提供了一种实现原子操作的办法。
sigsuspend是信号安全函数,将sigpromask和pause变成’原子’操作。
且利用参数sigset_t类型的参数(信号集)可以指定我们要阻塞等待的信号。
用siglongjmp和sigsetjmp进行程序控制
siglongjmp和sigsetjmp提供的功能: 信号处理程序直接跳转到期望的终止点。这个跳转过程需要解开程序栈。
将这两个函数与goto语句的用法做类比
【注意】只是表面的类比,内部实现有很大不同:
- sigsetjmp函数类似于goto语句的标签;
- 而siglongjump函数类似于一条goto语句。
- 最主要的区别是这对函数在跳转时 —— 清除了栈和信号的状态。
八、 时间和定时器
不具体介绍,使用时查看。
计时方式
格林威治时间是绝对的,和机器运行状态无关,而操作系统提供了四种计时方式(宏)。
CLOCK_REALTIME格林尼治时间
CLOCK_MONOTONIC开机时间
CLOCK_PROCESS_CPUTIME-ID当前代码(进程)所占用cpu时间
CLOCK_THREAD_CPUTIME-ID 当前线程所占用cpu时间
上面四个的clock_id分别代表1、2、3、4
补充:线程安全扩展
Time_t的指针参数 *restrict timer存储在寄存器(公共变量中,多个线程都可以访问)而不是独立变量中,多个线程在时间片轮转下的获取时间操作可能会使上一个线程获取的时间被新的时间覆盖;
九、shell、进程组、会话、终端
什么是进程组?
进程组是一个或多个进程的集合、通常共同完成一项作业,可以接受来自同一终端的信号。
(“接收来自同意终端的信号”尤为重要,在实际操作中在打开多个终端的情况下,可以看到按下Ctrl+C只会终止单个终端的前台进程)
【注意】每个进程组有一个领头进程。
进程组ID:PGID
PGID一般情况下和进程组的首个进程的PID一致。
int setpgid(pid,pgid)—— 把进程pid加入到pgid进程组中
pid_t getpgid(void) —— 获取当前进程组ID。
会话是什么?
共享一个终端的若干进程组,围绕一个共同的终端进行管理和控制。
当有新的用户登录 Linux 时,登录进程会为这个用户创建一个会话。
会话的意义在于将很多的工作集中在一个终端,选取其中一个作为前台来直接接收终端的输入及信号,其他的工作则放在后台执行。
一个会话的组成
- 会话的首进程(也是会话的控制进程——就是shell进程)
因为终端是一个设备,设备是一种文件,要能使用一个文件必须有由一个进程将其打开,该进程就是shell进程,由它创建/开启会话。
-
前端进程组(只有1个或者0个) -
后端进程组(数量不限)
?
【注意】Init系统进程绑定的终端是硬件终端,而Shell等其他进程绑定的是软件终端。
创建终端
绑定终端
Fork
Exec决定使用前端进程/后端进程
PID,PGID,SID
① Shell的pid、pgid、sid理论上都是一样的
② 前台进程组:前台进程组首进程的pid和其pgid相同,但与shell的pgid不同,与其组内被它fork出来的进程的pgid相同;sid和shell的相同。
③ 后端进程和前端进程情况类似。
setsid()函数
pid_t setsid(void)
如果调用该函数的进程不是一个进程组的组长(用fork来保证),则此函数创建一个会话,具体会发生以下三件事:
① 该进程变成新会话的会话首进程,该会话的SID是该进程的PID;
② 该进程变成一个新进程的进程组组长进程,该进程组的PGID是该进程的PID;
③ 该进程没有控制终端,如果在setsid之前该进程绑定了一个控制终端,那么这种联系也被切断。
【注意】在会话绑定控制终端之前,不区分前台进程组和后台进程组。
控制终端
一个会话有绑定一个控制终端的权力,控制终端通常有两种类型:
① 终端设备(在终端登录情况下)
② 伪终端设备(在网络登录情况下)
与终端绑定的会话首进程被称为(终端)控制进程,在shell中,”登录shell”进程(命令解释程序)就是终端控制进程,
控制本体:会话首进程/控制进程(有可能不是首进程)
控制对象:前台/后台进程组
控制方式:前后台都可以用命令行执行
(1) 前台 —— 可以直接和终端交互(接受来自终端的信号、占据终端控制权–终端输入)
(2) 后台 —— 不可以直接和终端交互(可以使用终端输出)
【注意】Ctrl+C对应的是中断信号,Ctrl+\对应的是推出信号。
关闭后端进程
- Ps aux查要关的进程号
- Kill发送中断信号
作业控制
控制哪一个作业(进程组)可以访问终端,哪些进程组在后台运行
Shell是什么?
是一个命令解释器(程序)—— 解释输入,输出交互。
Shell进程的工作
读入字符串,然后对字符做解析,执行相应操作(fork、exec以及有可能的重定向等)
注意一个细节:
①因为在fork之后父进程的信号掩码在子进程中是被清空的,故fork之后执行exec操作的过程要重设信号掩码。
②进程组和会话在fork的时候都是会被继承的,更改要用setsid(void)
相关函数:
Pid_t setsid(void)
Pid_t getsid(pid_t)
登录Shell的生成
Init用于本地的登录;inetd用于网络远程登录。
然后exec——Login
登陆成功后Exec——login shell(就是会话控制进程/会话首进程)
Login shell通过driver(驱动)绑定terminal(终端)
Fork&&exec后Setpgid —— 产生前端进程组
-
前台进程组也是绑定和shell同一终端的(IO) -
后台进程组类似 -
后端不能直接和终端交互(关掉标准io),前端可以
会话结构体(看apueP247)
守护进程
创建守护进程的流程
① 编写程序
-
fork()后结束父进程,子进程变成孤儿进程,但还属于原会话 -
调用setsid()创建新会话 (保证该进程不是会话首进程)。这时候是没有绑定终端设备和终端控制进程的(虽然但是,可以绑定终端,但是要求不与终端产生联系,此时还需要再fork一次来保证不是会话首进程,没有与终端绑定的可能性)。 -
重复1的操作,保证彻底脱离终端(新进程因不是会话首进程,失去绑定终端的权力)。 -
chdir()更改工作目录的位置 -
umask(0022)该变文件访问权限掩码 -
重定向标准IO到黑洞文件(/dev/null)中 fd = open("/dev/null", O_RDWR);
for(int i = 0; i < 3; i++)
dup2(fd, i);
-
关闭所有件描述符
② 开机启动
- 不是单纯由程序完成的,要由操作系统控制,写shell脚本。
cd /XXX
./XXX
bash
- 添加开机启动项
vim /etc/rc.local
加上运行脚本的行
③ 单实例化
文件的v节点内有fd lock锁(文件锁)—— /var/run/daemon.d ;
进程之间对文件操作的同步——可以实现单实例化;
十、 并发
进程组和进程中的线程列表有什么差异?
除了VM中的栈区,进程中其他的资源包括VM的BBS、DATA、HEAP、ENV等以及进程组/会话和进程凭证、信号、fdlist、syscall都可以共享。
即使是相同的资源,进程间也不可以共享,而且进程间做信息的通信时候不论是共享内存还是文件还是网络的方式都需要经过系统调用(含中断)和上下文切换的开销,而线程间的通信只需要利用指针(地址)去访问进程地址空间(主要是VM)中的资源。另外进程的地址开销比线程大(线程只需要解决协调的问题)。
本地监控文件描述符的六种方式
更细节的笔记:监控文件描述符的六种方式(进程监控、select、poll、非阻塞轮询I/O、异步I/O、线程监控)_狱典司的博客-CSDN博客
方法1:用进程来监控文件描述符 方法2 :使用select来监视文件描述符 (数组) 方法3:使用poll来监视文件描述符 (链表) 方法4: 带有轮询的无阻塞型I/O (cpu浪费) 方法5: POSIX异步I/O(基于信号) 方法6: 每个文件描述符都由一个独立的线程来监视
【拓】方法7:使用epoll让内核例程代为监控。
线程并发
基于进程资源实现多操作并发。
关于pthread的函数操作:pthread详解_networkhunter的博客-CSDN博客_phread
每个线程在进程内部有唯一的标识符(跨进程不适用)。
pthead_create()函数
在Linux下创建的线程的API接口是pthread_create() ,它的完整定义是:
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void*),
void *arg
);
函数参数:
-
线程句柄 thread:当一个新的线程调用成功之后,就会通过这个参数将线程的句柄返回给调用者,以便对这个线程进行管理。 -
线程属性 attr: pthread_create() 接口的第二个参数用于设置线程的属性。这个参数是可选的,当不需要修改线程的默认属性时,给它传递NULL就行。具体线程有那些属性,我们后面再做介绍。 -
线程回调函数 start_routine(): 当你的程序调用了这个接口之后,就会产生一个线程,而这个线程的入口函数就是start_routine()。如果线程创建成功,这个接口会返回0。 -
*回调函数的参数 arg : start_routine() 函数有一个参数,这个参数就是pthread_create 的最后一个参数arg。这种设计可以在线程创建之前就帮它准备好一些专有数据,最典型的用法就是使用C++编程时的this 指针。start_routine() 有一个返回值,这个返回值可以通过pthread_join() 接口获得。
【例】使用pthread_create创建一个线程并绑定回调函数:
#include <stdio.h>
#include <pthread.h>
void* thread( void *arg )
{
printf( "This is a thread and arg = %d.\n", *(int*)arg);
*(int*)arg = 0;
return arg;
}
int main( int argc, char *argv[] ){
pthread_t th;
int ret;
int arg = 10;
int *thread_ret = NULL;
ret = pthread_create( &th, NULL, thread, &arg );
if( ret != 0 ){
printf( "Create thread error!\n");
return -1;
}
printf( "This is the main process.\n" );
pthread_join( th, (void**)&thread_ret );
printf( "thread_ret = %d.\n", *thread_ret );
return 0;
}
将这段代码保存为thread.c 文件,可以执行下面的命令来生成可执行文件: $ gcc thread.c -o thread -lpthread 这段代码的执行结果可能是这样:
$ ./thread
This is the main process.
This is a thread and arg = 10.
thread_ret = 0.
注意只是可能有这样的结果,在不同的环境下可能会有出入。因为这是多线程程序,线程代码可能先于第24行代码被执行。
线程的属性
线程是有属性的,这个属性由一个线程属性对象来描述。线程属性对象pthread_attr_t *attr由pthread_attr_init() 接口初始化,并由pthread_attr_destory() 来销毁,它们的完整定义是:
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destory(pthread_attr_t *attr);
一般地,Linux下的线程有:绑定属性、分离属性、调度属性、堆栈大小属性和满占警戒区大小属性。
【例】设置线程绑定属性
#include <stdio.h>
#include <pthread.h>
……
int main( int argc, char *argv[] )
{
pthread_attr_t attr;
pthread_t th;
……
pthread_attr_init( &attr );
pthread_attr_setscope( &attr, PTHREAD_SCOPE_SYSTEM );
pthread_create( &th, &attr, thread, NULL );
……
}
【例】 设置线程分离属性
#include <stdio.h>
#include <pthread.h>
……
int main( int argc, char *argv[] )
{
pthread_attr_t attr;
pthread_t th;
……
pthread_attr_init( &attr );
pthread_attr_setscope( &attr, PTHREAD_SCOPE_SYSTEM );
pthread_create( &th, &attr, thread, NULL );
……
}
pthread_kill与kill的区别
① kill只能对终端用户所拥有的进程发送信号。
② Pthread_kill只在进程内部的线程列表范围内发送信号。
线程的合并pthread_join()
pthread_create() 接口负责创建了一个线程。线程也属于系统的资源,这跟内存没什么两样,而且线程本身也要占据一定的内存空间。
众所周知的一个问题就是C/C++编程中如果要通过malloc() 或new 分配了一块内存,就必须使用free() 或delete 来回收这块内存,否则就会产生著名的内存泄漏问题。
既然线程和内存没什么两样,那么有创建就必须得有回收,否则就会产生另外一个著名的资源泄漏问题,这同样也是一个严重的问题。那么线程的合并就是回收线程资源了。
线程的合并是一种主动回收线程资源的方案。当一个进程或线程调用了针对其它线程的pthread_join() 接口,就是线程合并了。这个接口会阻塞调用进程或线程,直到被合并的线程结束为止。当被合并线程结束,pthread_join() 接口就会回收这个线程的资源,并将这个线程的返回值返回给合并者。
线程的分离pthread_detach()
与线程合并相对应的另外一种线程资源回收机制是线程分离,调用接口是pthread_detach() 。
线程分离是将线程资源的回收工作交由系统自动来完成,也就是说当被分离的线程结束之后,系统会自动回收它的资源。因为线程分离是启动系统的自动回收机制,那么程序也就无法获得被分离线程的返回值,这就使得pthread_detach() 接口只要拥有一个参数就行了,那就是被分离线程句柄(pthread_join有两个参数,一个是线程句柄,另一个是传出参数,用于接受线程结束时的返回值)。
【注意】
- 线程合并和线程分离都是用于回收线程资源的,可以根据不同的业务场景酌情使用。不管有什么理由,你都必须选择其中一种,否则就会引发资源泄漏的问题,这个问题与内存泄漏同样可怕。
- 除了调用pthread_detach和pthread_join之外,还可以在pthread_create时设置线程属性。
线程分离和线程合并的区别
-
简单来说: pthread_detach()即主线程与子线程分离,子线程结束后,资源由系统自动回收。pthread_join()即是子线程合入主线程,主线程阻塞等待子线程结束,然后回收子线程资源。 -
在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源(例如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放。
- 默认情况下,线程被创建成可结合的。为了避免存储器泄漏,每个可结合线程都应该要么被显示地回收,即调用pthread_join;要么通过调用pthread_detach函数被分离。
Pthread_eitx
可以单独终止某个线程,并且返回一个存储计算结果的指针,该指针指向进程地址空间VM中的全局或堆中栈(而不能是线程栈);
一般是正常执行完毕由由线程自己调用pthread_exit终止。
Pthread_cancel
用参数标识出线程并主动将其杀死。
有点类似于进程的abort,属于非正常执行,一般由别人杀死。
线程使用的注意事项
- 线其他程不退出,主线程调用pthread exit
- 避免僵线程
- pthread join
- pthread detach
- pthread create指定分离属性
- 被join线程可能在join函数返回前就释放完自己的所有内存资源,所以不应当返回被回收线程栈中的值(一般返回进程VM中某个线程可共享空间,除了进程栈外);
- malloc和mmap申请的内存可以被其他线程释放。
- 应避免在多线程模型中调用fork除非,马上exec,子进程中只有调用fork的线程存在,其他线程在子进程
中均pthread_exit - 信号的复杂语义很难和多线程共存,应避免在多线程引入信号机制。
十一、线程同步
线程的交互
(1) 线程结束时的返回value —— 可以通过pthread_join来获取
(2) 进程的内部线程可以通过信号交流(pthread_kill)
【注意】
Pthread_kill因为参数中需要线程号,所以外部进程无法向另一个进程内部的线程发送信号;
而由于进程没有线程号(线程属性结构体),所以线程不可以用pthread_kill向进程发信号
(3) 本质上是生产者和消费者的问题,需要一个共享区(缓冲区)以及同步机制
数据混乱的原因:
-
资源共享(独享资源则不会) -
调度随机(意味着数据访问会出现竞争) -
线程间缺乏必要同步机制
对同步机制的理解
一定时间内(有可能被时间片分割的时间段,具有一定长度)
确定某特定交互区域(必然在公共区域中)被独占/独享。
即,对公共区域的数据按序访问。防止数据混乱,产生与时间有关的错误。
线程安全:
线程返回结果必须有明确而独立的的存储位置(地址等)。
同步机制(锁)
锁可以把非线程安全函数转变成线程安全的,锁保护的是返回结果的独享位置或操作的独占。
POSIX线程提供了三种锁机制:
(1) 互斥锁 —— mutex
(2) 条件变量 —— conditional variable (增加使用条件)(判断过程中不能更改)
(3) 读写锁 —— 对于互斥锁的细化
【注意】三种锁都是很小的存储单元(都是变量),对于这类单元的访问可以是原子的,上锁和解锁这个操作是原子操作,不可分拆。
怎么理解建议锁?
建议锁!对公共数据进行保护。所有线程【应该】在访问公共数据前先拿锁再访问。
但,锁本身不具备强制性。
对于程序员来说,锁和临界资源在某种意义上是绑定(该绑定由程序员完成)的;
对于系统来说可以没有这种逻辑限制(比如攻击者可以利用内存泄漏暴露的地址直接访问被锁保护的临界资源),
但对于锁本身是系统内部的机制(包含队列和上锁线程记录)。
———— 即建议锁。
一、 互斥锁mutex
本质上是一种特殊的变量,有两种状态,即锁定和解锁;
互斥锁实际上与信号相关联,每个锁变量有独立的等待队列(将后续线程编号加入等待队列);
创建这个变量的时候就会创建并初始化等待队列,争夺锁失败的线程会被加入等待队列;
解锁的时候会发送信号通知等待队列中的线程(具体实现与操作系统的具体实现有关)。
pthread_mutex_t mutex
Int pthread_mutex_init( * mutex , attr )
Int pthread_mutex_destroy( * mutex )
上锁: int pthread_mutex_lock( * mutex )
解锁: int pthread_mutex_unlock( * mutex )
非阻塞上锁: int pthrea_mutex_trylock( * mutex )
【注意】
上锁时系统记录上锁线程的属性struct_pthread_attr_t,解锁时要对照这个属性,非上锁线程无权限解锁!
互斥锁的使用技巧
二、 条件变量
对临界区域(则必须有互斥锁,没有互斥锁就没有临界区这个概念)的访问是有条件的(不仅独占还有条件)。
【注意】条件变量不是锁,但是通常结合锁来使用。
初始化条件变量:
pthread_cond_init(&cond, NULL); 动态初始化。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; 静态初始化。
【注意】上锁与判断的顺序:先上锁再判断,将判断作为临界资源的一部分
阻塞等待条件:
pthread_cond_wait(&cond, &mutex);
【作用】
阻塞等待条件变量queue_not_empty 并解开mutex互斥锁,直到条件变量满足则上mutex锁并执行:
- 阻塞等待条件变量满足。
- 解锁已经加锁成功的信号量 (相当于 pthread_mutex_unlock(&mutex)),12两步为一个原子操作。
- 当条件满足,函数返回时,解除阻塞并重新申请获取互斥锁。重新加锁信号量 (相当于pthread_mutex_lock(&mutex);)。
唤醒阻塞在条件变量上的线程
pthread_cond_signal(): 唤醒阻塞在条件变量上的 (至少)一个线程。
pthread_cond_broadcast(): 唤醒阻塞在条件变量上的 所有线程。
使用条件变量和互斥锁配合的例子
int threadpool_add(threadpool_t *pool, void*(*function)(void *arg), void *arg)
{
pthread_mutex_lock(&(pool->lock));
while ((pool->queue_size == pool->queue_max_size) && (!pool->shutdown)) {
pthread_cond_wait(&(pool->queue_not_full), &(pool->lock));
}
if (pool->shutdown) {
pthread_cond_broadcast(&(pool->queue_not_empty));
pthread_mutex_unlock(&(pool->lock));
return 0;
}
if (pool->task_queue[pool->queue_rear].arg != NULL) {
pool->task_queue[pool->queue_rear].arg = NULL;
}
pool->task_queue[pool->queue_rear].function = function;
pool->task_queue[pool->queue_rear].arg = arg;
pool->queue_rear = (pool->queue_rear + 1) % pool->queue_max_size;
pool->queue_size++;
pthread_cond_signal(&(pool->queue_not_empty));
pthread_mutex_unlock(&(pool->lock));
return 0;
}
三、读写锁
读写锁在互斥锁的基础上增加了读和写的互斥规则(对互斥锁的细化)。
相较于互斥量而言,当读线程多的时候,提高访问效率。
读写锁的操作接口
pthread_rwlock_t rwlock;
pthread_rwlock_init(&rwlock, NULL);
pthread_rwlock_rdlock(&rwlock); try
pthread_rwlock_wrlock(&rwlock); try
pthread_rwlock_unlock(&rwlock);
pthread_rwlock_destroy(&rwlock);
读写锁的工作原理:
锁只有一把,但是有读模式加锁和写模式加锁;
① 写独占
② 读之间可以共享
③ 写锁优先级高
【注意】
- 写锁优先级高,但是在写锁进行上锁操作之前要是有读锁已经上锁了的话,不能把读锁踢出去,而是等其读完解锁后才能上写锁; 所谓写锁优先级高是指读和写请求同时到达时优先上写锁。
- 上写锁后禁止并发操作,即只能单一线程写,其他线程不能读写。
【例】下图中若是T1先来,T2~4一起来: T1的read请求先上锁…解锁;T3作为read被T2和T4的write阻塞;T2和T4按序写;写完之后才轮到T3来read。
死锁
是使用锁不恰当导致的现象:
- 对一个锁反复lock
- 两个线程,各自持有一把锁,请求另一把
什么是锁? 常见的锁的类型?锁的作用机制是怎么样的?
① 锁的理解:
② 常见的锁类型:
(1)互斥锁 (2)条件变量 (3)读写锁
③ 锁的工作机制:
(1)互斥锁:
- 本质上是一种特殊的原子可操作的变量,有两种状态,即锁定和解锁;
- 互斥锁实际上与信号相关联,每个锁变量有独立的等待队列;
- 创建一个互斥锁变量的时候就会创建并初始化等待队列,争夺锁失败的线程会被加入等待队列,解锁的时候会发送信号通知等待队列中的线程(具体实现与操作系统的具体实现有关)。
(2)条件变量:
- 对具有互斥锁保护的临界区域的访问增加条件判断(不仅独占还有条件)。
- 严格意义上条件变量并不是锁,但通常结合互斥锁使用。
(3)读写锁:
十二、信号量
信号量:应用于线程、进程间同步。
信号量与条件变量的比较
① 相当于初始化值为 N 的互斥量; N值表示可以同时访问共享数据区的线程数。
② 条件变量cond用于线程同步,sem可以用于进程/线程同步( sem_init中的pshared参数)。
相关函数
sem_t sem; 定义类型。
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem_init的参数:
- sem: 信号量
- pshared: 0 用于线程间同步; 1 用于进程间同步
- value:N值。(指定同时访问的线程数)
sem_destroy();
sem_wait();
一次调用,做一次-- 操作, 当信号量的值为 0 时,再次 – 就会阻塞。 (对比 pthread_mutex_lock)
sem_wait();
一次调用,做一次-- 操作, 当信号量的值为 0 时,再次 – 就会阻塞。 (对比 pthread_mutex_lock)
sem_post();
一次调用,做一次++ 操作. 当信号量的值为 N 时, 再次 ++ 就会阻塞。(对比 pthread_mutex_unlock)
基于信号量实现生产者消费者问题
1.
2.
3. #include <stdlib.h>
4. #include <unistd.h>
5. #include <pthread.h>
6. #include <stdio.h>
7. #include <semaphore.h>
8.
9. #define NUM 5
10.
11. int queue[NUM];
12. sem_t blank_number, product_number;
13.
14. void *producer(void *arg)
15. {
16. int i = 0;
17.
18. while (1) {
19. sem_wait(&blank_number);
20. queue[i] = rand() % 1000 + 1;
21. printf("----Produce---%d\n", queue[i]);
22. sem_post(&product_number);
23.
24. i = (i+1) % NUM;
25. sleep(rand()%1);
26. }
27. }
28.
29. void *consumer(void *arg)
30. {
31. int i = 0;
32.
33. while (1) {
34. sem_wait(&product_number);
35. printf("-Consume---%d\n", queue[i]);
36. queue[i] = 0;
37. sem_post(&blank_number);
38.
39. i = (i+1) % NUM;
40. sleep(rand()%3);
41. }
42. }
43.
44. int main(int argc, char *argv[])
45. {
46. pthread_t pid, cid;
47.
48. sem_init(&blank_number, 0, NUM);
49. sem_init(&product_number, 0, 0);
50.
51. pthread_create(&pid, NULL, producer, NULL);
52. pthread_create(&cid, NULL, consumer, NULL);
53.
54. pthread_join(pid, NULL);
55. pthread_join(cid, NULL);
56.
57. sem_destroy(&blank_number);
58. sem_destroy(&product_number);
59.
60. return 0;
61. }
十三、进程间通信IPC
本地: 文件、管道、文件加锁
内存: 进程内:线程
进程间—隔离—共享内存放在内核中
硬件: Unix域
基本单位层级
-
做事(作业)的时候基本单位是进程组。 -
进程是操作系统资源分配的基本单位。 -
系统调度的基本单位是线程。
操作系统内存使用
出于安全的考虑划分成两块:内核区域和用户区域
- 内核区:由操作系统代码访问控制的区域(不允许外来用户直接控制),把行为限制在系统调用中。
用户与系统之间存在隔离。
- 用户区域:满足个性化需求;需要通过系统调用(systemcall中的函数指针)来访问内核区。
由于操作系统支持多用户、会话、进程组 -->用户间的隔离:各用户的进程只能访问自己的VM。
进程间通信
通信的核心:可供多端读写的公共介质。
- 进程实体 :①信号
- 内存: ①信号量集 ②消息队列 ③共享内存
- 文件: ①匿名管道(pipe) ②命名管道(FIFO) ③自定义文件
- socket: ①涉及时间上的先后和时空上的转换
内存中的共享内存和文件中的自定义文件没有提供锁机制:
解决方案:
-
内存:找一段地址自定义一个锁(可以用1个bit的0和1表示)。 -
自定义文件:文件锁。
信号量和信号量集
(1) 信号量 (用户空间)---- 同一进程的线程之间或者多个进程之间。
(2) 信号量集(内核空间)---- 进程之间使用(多个信号量则表示多种资源的生产和消费)
共享内存
所谓共享内存就是使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。
- 进程间能把同一段共享内存段“连接/映射到”他们自己的地址空间里去。所有进程都能访问共享内存中的地址。
- 如果一个进程向这段共享内存写了数据,所做的改动会即时被有访问同一段共享内存的其他进程看到。
- 共享内存的使用大大降低了在大规模数据处理过程中内存的消耗,但是共享内存的使用中有很多的陷阱,一不注意就很容易导致程序崩溃。
下面实例演示了使用shmget函数创建一块共享内存。
程序中在调用shmget函数时指定key参数值IPC_PRIVATE,这个参数的意义是创建一个新的共享内存区,创建成功后使用shell命令ipcs来显示系统下共享内存的状态。命令参数-m为只显示共享内存的状态。
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <stdio.h>
#define BUFSZ 4096
int main ( void )
{
int shm_id;
shm_id=shmget(IPC_PRIVATE, BUFSZ, 0666 ) ;
if (shm_id < 0 ) {
perror( "shmget" ) ;
exit ( 1 );
}
printf ( "successfully created segment : %d \n", shm_id ) ;
system( "ipcs -m");
exit( 0 );
}
共享内存相比其他几种方式有着更方便的数据控制能力,数据在读写过程中会更透明。当成功导入一块共享内存后,它只是相当于一个字符串指针来指向一块内存,在当前进程下用户可以随意的访问。缺点是,数据写入进程或数据读出进程中,需要附加的数据结构控制(往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。)。
消息队列
基于内核的内存实现(但是与其功能类似的FIFO是基于文件实现的)。
- 消息被发送到队列中。“消息队列”是在消息的传输过程中保存消息的容器。
- 消息队列管理器在将消息从它的源中继到它的目标时充当中间人。队列的主要目的是提供路由并保证消息的传递;如果发送消息时接收者不可用,消息队列会保留消息,直到可以成功地传递它。
进程间配合
- 同步:相互协调,涉及到对顺序的约定,有序进行。
- 异步:不关心关联操作的顺序。
【情况】
-
配合关系(两个实体间的关系(两两)) -
竞争关系(一种资源的使用权)
网络编程和系统编程的区别
网络编程基于分布式的计算资源(主机)实现不同进程间的配合。
系统编程是基于本地主机的进程。
十四、网络套接字
分层模型结构
c/s模型(client-server)
优点:缓存大量数据、协议选择灵活、速度快
缺点:安全性、跨平台、开发工作量较大
b/s模型(browser-server)
优点:安全性、跨平台、开发工作量较小
缺点:不能缓存大量数据、严格遵守 http
网络传输流程
数据没有封装之前,是不能在网络中传递。
数据–>应用层–>传输层–>网络层–>链路层–>网络环境
?
**ARP协议:**根据 Ip 地址获取 mac 地址。
**以太网帧协议:**根据mac地址,完成数据包传输。
IP协议:
版本: IPv4、IPv6 – 4位
TTL: time to live 。 设置数据包在路由节点中的跳转上限。每经过一个路由节点,该值-1, 减为0的路由,有义务将该数据包丢弃
源IP: 32位。— 4字节 192.168.1.108 — 点分十进制 IP地址(string) — 二进制
目的IP:32位。— 4字节
IP地址:可以在网络环境中,唯一标识一台主机。
端口号:可以网络的一台主机上,唯一标识一个进程。
ip地址+端口号:可以在网络环境中,唯一标识一个进程。
UDP 16位:源端口号。 2^16 = 65536
? 16位:目的端口号。
TCP
16位:源端口号。 2^16 = 65536
16位:目的端口号。
32序号;
32确认序号。
6个标志位。
16位窗口大小。 2^16 = 65536
网络套接字: socket
一个文件描述符指向一个套接字(该套接字内部由内核借助两个缓冲区实现)
在通信过程中, 套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。我们使用同一个文件描述符索发送缓冲区和接收缓冲区。
网络字节序
小端法:(pc本地存储)—— 高位存高地址。低位存低地址。 int a = 0x12345678
大端法:(网络存储)—— 高位存低地址。低位存高地址。
htonl --> 本地–>网络 (IP) 192.168.1.11 --> string --> atoi --> int --> htonl --> 网络字节序
htons --> 本地–>网络 (port)
ntohl --> 网络–> 本地(IP)
ntohs --> 网络–> 本地(Port)
IP地址转换函数
(1)int inet_pton(int af, const char *src, void *dst);
本地字节序(string IP) ---> 网络字节序
af:AF_INET、AF_INET6
src:传入,IP地址(点分十进制)
dst:传出,转换后的 网络字节序的 IP地址。
返回值:
成功: 1
异常: 0, 说明src指向的不是一个有效的ip地址。
失败:-1
(2)const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
网络字节序 ---> 本地字节序(string IP)
af:AF_INET、AF_INET6
src: 网络字节序IP地址
dst:本地字节序(string IP)
size: dst 的大小。
返回值: 成功:dst。
失败:NULL
sockaddr地址结构
sockaddr地址结构: IP + port --> 在网络环境中唯一标识一个进程。
struct sockaddr_in addr;
addr.sin_family = AF_INET/AF_INET6
addr.sin_port = htons(9527);
int dst;
inet_pton(AF_INET, "192.157.22.45", (void *)&dst);
addr.sin_addr.s_addr = dst;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
socket函数
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain:AF_INET、AF_INET6、AF_UNIX
type:SOCK_STREAM、SOCK_DGRAM
protocol: 0
返回值: 成功: 新套接字所对应文件描述符
? 失败: -1 errno
bind()函数
#include <arpa/inet.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd: socket 函数返回值
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr: 传入参数**(struct sockaddr )&addr* 【注意】做强制类型转换。
addrlen: sizeof(addr) 地址结构的大小。
返回值:成功:0,失败:-1 errno。
listen函数
int listen(int sockfd, int backlog);
sockfd: socket 函数返回值
backlog:上限数值。最大值 128.
返回值: 成功:0
? 失败:-1 errno
accept函数
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockfd: socket 函数返回值
addr:传出参数。成功与服务器建立连接的那个客户端的地址结构(IP+port)
socklen_t clit_addr_len = sizeof(addr);
addrlen:传入传出。 &clit_addr_len
入:addr的大小。 出:客户端addr实际大小。
返回值:
? 成功:能与客户端进行数据通信的 socket 对应的文件描述。
? 失败: -1 , errno
?
connect函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd: socket 函数返回值
struct sockaddr_in srv_addr; // 服务器地址结构
srv_addr.sin_family = AF_INET;
srv_addr.sin_port = 9527 跟服务器bind时设定的 port 完全一致。
inet_pton(AF_INET, “服务器的IP地址”,&srv_adrr.sin_addr.s_addr);
addr:传入参数。服务器的地址结构
addrlen:服务器的地址结构的大小
返回值: 成功:0
? 失败:-1 errno
如果不使用bind绑定客户端地址结构, 采用"隐式绑定"。
TCP通信流程分析
server:
-
socket() 创建socket -
bind() 绑定服务器地址结构 -
listen() 设置监听上限 -
accept() 阻塞监听客户端连接 -
read(fd) 读socket获取客户端数据 -
处理 -
write(fd) -
close();
client:
-
socket() 创建socket -
connect(); 与服务器建立连接 -
write() 写数据到 socket -
read() 读处理后的数据。 -
显示读取结果 -
close()
更多的内容涉及网络编程,不展开叙述。
十五、异步/同步 和 阻塞/非阻塞的讨论
简要理解
①同步/异步关注的是消息通信机制 (synchronous communication/ asynchronous communication) 。
所谓同步,就是在发出一个调用时,在没有得到结果之前, 该调用就不返回。 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。
【例】
- 同步:线程的互斥锁机制
- 异步:epoll的监听机制
②阻塞/非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
深入理解
阻塞/非阻塞, 同步/异步的概念要注意讨论的上下文(字面意思):
① 在进程通信层面: 阻塞/非阻塞, 同步/异步基本是同义词, 但是需要注意区分讨论的对象是发送方还是接收方。
发送方阻塞/非阻塞(同步/异步)和接收方的阻塞/非阻塞(同步/异步) 是互不影响的。
- 阻塞式发送(blocking send). 发送方进程会被一直阻塞, 直到消息被接受方进程收到。
- 非阻塞式发送(nonblocking send)。 发送方进程调用 send() 后, 立即就可以其他操作。
- 阻塞式接收(blocking receive) 接收方调用 receive() 后一直阻塞, 直到消息到达可用。
- 非阻塞式接受(nonblocking receive) 接收方调用 receive() 函数后, 要么得到一个有效的结果, 要么得到一个空值, 即不会被阻塞。
【注意】上述不同类型的发送方式和不同类型的接收方式,可以自由组合。
② 在 IO 系统调用层面( IO system call ):
非阻塞IO 系统调用 和 异步IO 系统调用存在着一定的差别, 它们都不会阻塞进程, 但是返回结果的方式和内容有所差别, 但是都属于非阻塞系统调用( non-blocing system call )
非阻塞系统调用(non-blocking I/O system call 与 asynchronous I/O system call) 的存在可以用来实现线程级别的 I/O 并发, 与通过多进程实现的 I/O 并发相比可以减少内存消耗以及进程切换的开销。
【原理】
考虑一个单进程服务器程序, 收到一个 Socket 连接请求后, 读取请求中的文件名,然后读请求的文件名内容,将文件内容返回给客户端。CPU 和 硬盘IO 的资源大部分时间都是闲置的。 此时, 我们会希望在等待 I/O 的过程中继续处理新的请求。
方案一: 多进程
- 每到达一个请求, 我们为这个请求新创建一个进程来处理。 这样, 一个进程在等待 IO 时, 其他的进程可以被调度执行, 更加充分地利用 CPU 等资源。
- 问题: 每新创建一个进程都会消耗一定的内存空间, 且进程切换也会有时间消耗, 高并发时, 大量进程来回切换的时间开销会变得明显起来。
方案二:多线程
- 和多进程方案类似,为每一个请求新建一个线程进行处理,这样做的重要区别是, 所有的线程都共享同一个进程空间。
- 问题: 需要考虑是否需要为特定的逻辑使用锁。
引申问题: 一个进程中的某一个线程发起了 system call 后, 是否造成整个进程的阻塞? 如果会, 那么多线程方案与单进程方案相比就没有明显的改善。
-
解决办法1:内核支持的线程(kenerl supported threads)
- 操作系统内核能够感知到线程, 每一个线程都会有一个内核调用栈(kenerl stack) 和 保存CPU 寄存器下文的 table 。
- 在这种方案中, 如果 CPU 是多核的, 不同的线程还可以运行在不同的 CPU 处理器上。 既实现了IO 并发, 也实现了 CPU 并发。
- 问题: 基于内核线程编写的应用会难以移植
- 不同的操作系统对于内核线程的支持方式统而言有所差别,甚至部分操作系统甚至不支持内核级别线程, 当应用代码基于内核线程进行开发后, 就使得应用层代码与特定的操作系统产生了耦合关系, 不能随意部署
-
解决办法2: 用户支持的线程(user supported threads)
- 内核感知不到用户线程, 每一个用户的进程拥有一个调度器, 该调度器可以感知到线程发起的系统调用, 当一个线程产生系统调用时, 不阻塞整个进程, 切换到其他线程继续运行。 当 I/O 调用完成以后, 能够重新唤醒被阻塞的线程。
- 实现细节:
- 应用程序基于线程库 thread libray 编写
- 线程库中包含 “虚假的” read(), write(), accept()等系统调用。
- 线程库中的 read(), write(), accept() 的底层实现为非阻塞系统调用(Non-blocking system call), 调用后,由于可以立即返回, 则将特定的线程状态标记为 waiting, 调度其他的可执行线程。 内核完成了 IO 操作后, 调用线程库的回调函数, 将原来处于 waiting 状态的线程标记为 runnable。
从上面的过程可以看出,用户支持线程的解决方案基于非阻塞IO系统调用( non-blocking system call) , 且是一种基于操作系统内核事件通知(event-driven)的解决方案, 基于这个流程, 可以引申到更为宽泛的 event-driven progreamming 话题上。
阻塞 / 非阻塞、同步 / 异步的区别与联系? 给出例子(同步/异步)
①同步/异步更多的关注消息通信机制:
(1)所谓同步,就是在发出一个调用时,在没有得到结果之前, 该调用就不返回。
(2)异步则是调用在发出之后,返回的结果必须是完整的, 但是这个操作完成的通知可以延迟到将来的一个时间点。
(3)通信的发送方阻塞/非阻塞(同步/异步)和接收方的阻塞/非阻塞(同步/异步) 是互不影响的。
-
同步的例子:线程互斥锁的使用。 -
异步的例子:epoll基于事件驱动的监听机制。
②阻塞/非阻塞关注的是在 IO 系统调用层面:
(1)阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
(2)非阻塞调用指操作立即返回的是任何可以立即拿到的数据, 可以是完整的结果, 也可以是不完整的结果, 还可以是一个空值,该调用不会阻塞当前线程。
- 阻塞的例子:慢速系统调用。
- 非阻塞的例子:用户级线程。
|