主要参考:
《程序员的自我修养》读书总结
编译与链接过程的思考
linux 下动态链接实现原理
研读《程序员的自我修养—链接、装载与库》
程序的静态链接,动态链接和装载
1. 源代码是怎么变成可执行文件的,每一步的作用是什么?(预编译,词法分析,语法分析,语义分析,中间语言生成目标代码生成,汇编,链接)
解答:
源代码变成可执行文件需要经历 4
个大步骤:
预处理——>编译——>汇编——>链接
引用自:编译与链接过程的思考
1. 预处理:
由“预编译器”
负责,负责将:
将所有的
#define
删除,并且展开所有的宏定义处理所有的条件预编译指令,比如
#if #ifdef #elif #else #endif
等处理
#include
预编译指令,将被包含的文件插入到该预编译指令的位置。删除所有注释
“//”
和”/* */”
.添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。
保留所有的
#pragma
编译器指令,因为编译器需要使用它们最终
.c
文件经过预编译,变为.i
文件。
2. 编译
由编译器负责,主要又由词法分析、语法分析、语义分析、优化
和生成汇编代码
五个部分:
词法分析:识别源代码中的各种括号、数字、标点等。比如有(但没有),这一步就能发现错误。
语法分析:这一步会生成语法树,比如
2+4
就是一颗根节点为+
,左右叶子节点分别为2
和4
的语法树。如果你只是写2+
,在这一步就会报错。语义分析:这一步主要考虑类型声明、匹配和转换。比如你写
2 * "3"
在这一步就会报错中间语言生成:这一步会生成平台无关的三地址码,比如
2 + 3
会写成t1 = 2 + 3
,同时也会把这样在编译期就可以确定的表达式进行优化目标代码生成:编译器根据三地址码生成依赖于目标机器的目标机器代码,也就是汇编语言
最后
.i
文件经过编译,得到汇编文件,后缀是.s
3. 汇编
汇编器
将汇编语言转换成机器可以执行的语言(完全由0
和1
组成).汇编文件经过汇编,变成目标文件,后缀为.o
。
4.链接
通过调用链接器ld来将多个目标文件以及所依赖的其它库文件链接起来,最后生成可执行文件。
考虑一个.c
文件中,用到了另一个.c
文件中的变量或函数。在编译这个文件时,我们无法在编译期确定这个变量或函数的地址。只有在把所有目标文件链接起来以后,才能确定。链接器主要负责地址重分配、符号名称绑定和重定位。
2. 应用层、API、运行库、系统调用、操作系统内核之间的关系是什么?
计算机调用结构:
最上层是应用层。不管是浏览器、游戏,还是我们使用的各种开发工具,如
Xcode
,VS
,汇编器自身
等,都属于这一范畴。第二层是操作系统的运行库。我们在程序里调用
系统API
,比如文件读写,就是调用了第二层提供的相应服务。这种调用通过操作系统的API
完成,它沟通了应用层
和操作系统的运行库
。这也就是为什么不管是在Mac
还是Windows
上编程,我们都可以调用printf()
或fread()
等函数。因为不同的操作系统的运行库提供了不同底层的实现,但对应用层提供的API
总是一样的。第三层是操作系统内核。操作系统的运行库通过
系统调用(System Call)
调用系统内核提供的函数。比如fread
属于API
,它在Linux
下会调用read()
这个系统调用,而在Windows
下会调用ReadFile()
这个系统调用。应用程序可以直接调用系统调用,但是这样一来,我们需要考虑各个操作系统下系统调用的不同,而且系统调用由于更加底层,实现起来也就更加困难。最关键的是,系统调用是通过中断来完成的,涉及到堆栈的保存与恢复,频繁的系统调用会影响性能。第四层是
硬件层
。程序无法直接访问这一层,只有操作系统的内核,通过硬件厂商提供的接口才能访问。
3. 虚拟内存空间是什么,为什么要有虚拟内存空间。
虚拟地址空间:是指应用程序自己认为,自己所处的地址空间。它区别于物理地址空间。后者是真实存在的,比如电脑有一根8G
的内存条,物理地址空间就是0~8Gb
。CPU
的MMU
(内存管理单元)负责把虚拟地址转换成物理地址。
引入虚拟地址的好处:
程序员不再关心真实的物理内存空间是什么样的,理论上来说(
64
位CPU
的虚拟内存为2^64
),程序员有几乎无限大的虚拟内存空间可用,最后只要建立虚拟地址和物理地址的对应关系即可。操作系统屏蔽了物理内存空间的细节,进程无法访问到操作系统禁止访问的物理地址,也不能访问到别的进程的地址空间,这大大增强了程序安全性。
由虚拟地址空间引申出来的
分页(Paging)
技术,大大提高了内存的使用效率。要想运行一个程序,不再需要把整个程序都放入内存中执行,我们只要保证将要执行的页在内存中即可,如果不存在则导致页错误。
4. 静态链接和动态链接分别表示什么,大概是怎么实现的?
静态链接:
是指链接阶段将多个目标文件和相关的静态库组合在一起形成一个可执行文件。
静态链接
包括两个大部分:一是空间和地址的分配
;二是符号解析和重定位
- 空间和地址分配:
扫描所有的输入目标文件,获得他们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这样,连接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
备注:映射关系就是指可执行文件与进程虚拟地址空间之间的映射。那么,这里程序还没有执行,更不会出现进程,哪里来的进程地址空间呢?此时虚拟存储器便发挥了很大的作用:虽然此时没有进程,但是每个进程的虚拟地址空间的格式都是一致的。所以,为可执行文件的每个段甚至每个符号符号分配地址也就不会有什么错了
- 符号解析与重定位:
符号解析:解析符号就是将每个符号引用与它输入的可重定位目标文件中的符号表中的一个确定的符号定义联系起来。若找不到,则出现编译时错误。
重定位:根据目标文件的重定位入口所修正的指令寻址方式,进行正确的寻址。
动态链接
是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。
动态链接
基本分为三步:先是启动动态链接器本身
,然后装载所有需要的共享对象
,最后重定位和初始化
。
动态链接器自举:
动态链接器有其自身的特殊性:首先,动态链接器本身不可以依赖其他任何共享对象(人为控制)
;其次动态链接器本身所需要的全局和静态变量的重定位工作由它自身完成(自举代码)
。
我们知道,在Linux
下,动态链接器ld.so
实际上也是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器。动态链接器入口地址即是自举代码的入口。动态链接器启动后,它的自举代码即开始执行。自举代码首先会找到它自己的GOT(全局偏移表,记录每个段的偏移位置)
。而GOT
的第一个入口保存的就是“.dynamic”
段的偏移地址,由此找到动态链接器本身的“.dynamic”
段。通过“.dynamic”
段中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,然后将它们重定位。完成自举后,就可以自由地调用各种函数和全局变量。装载共享对象:
完成自举后,动态链接器
将可执行文件
和链接器本身
的符号表都合并到一个符号表当中,称之为“全局符号表”
。然后链接器开始寻找可执行文件所依赖的共享对象
:从“.dynamic”段
中找到DT_NEEDED
类型,它所指出的就是可执行文件所依赖的共享对象
。由此,动态链接器
可以列出可执行文件所依赖的所有共享对象,并将这些共享对象的名字放入到一个装载集合
中。然后链接器开始从集合中取出所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头
和“.dynamic”
,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象
还依赖于其他共享对象,那么将依赖的共享对象的名字放到装载集合中。如此循环,直到所有依赖的共享对象都被装载完成为止。重定位和初始化:
当上述两步完成以后,动态链接器
开始重新遍历可执行文件
和每个共享对象的重定位表
,将表中每个需要重定位的位置进行修正,原理同前。
重定位完成以后,如果某个共享对象有“.init”段
,那么动态链接器会执行“.init”段
中的代码,用以实现共享对象特有的初始化过程
5. 可执行文件的结构如何?(分为哪些段)
ELF Header
: 用来描述整个文件的组织,文件类型,机器类型,程序入口地址
。Program Header Table:
描述了ELF文件
该如何被操作系统
映射到进程的虚拟空间
。
(备注:可执行文件
中有多个Segment
,从装载
的角度,我们目前只关心两个“Load”
类型的Segment
,因为只有它是需要被映射的,其他的诸如“NOTE”、“TLS”、“GNU_STACK”
都是在装载时起辅助作用的)
Segment 1
: 目标文件中可读可执行的section
的集合,如.text,.init
等Segment 2:
目标文件中可读可写的section
的集合。如.data,.bss
等Section Header Table optional:
ELF Header
中描述的节区头描述表
的起始位置,文件包含了多少个节,这个数组就有多少个成员,每个成员描述了对应一个节区的名字,偏移地址
,虚拟地址
,可读写信息
等.
6. 可执行文件是怎么装载进内存的,为什么要分段,分页,页错误是什么?
1). 可执行文件的装载:
比如当我们在
Linux系统
的bash
下输入一行命令执行某个ELF
程序时,在用户层面,bash进程
会调用fork()系统调用
创建一个新的进程,然后新的进程调用execve()
来执行ELF文件
,原先的bash
进程返回等待刚启动新进程结束,然后继续等待用户输入命令。新创建的进程,操作系统会为它创建一个独立的虚拟地址空间(虚拟空间实际上并不是创建空间而是创建映射函数所需要的数据结构。)
在进入
execve()函数
调用后,Linux内核
就开始进行真正的装载工作。在内核中execve()系统调用
相应的入口sys_execve()
来进行参数的检查复制,然后调用do_execve()
函数来查找被执行的文件,读取文件的前128个字节来判断文件的格式是elf
还是其他;接着调用search_binary_handle()函数
来判断文件头部的魔数,确定文件的格式并且调用相应的装载处理程序。ELF可执行文件
的装载处理过程叫load_elf_binary()
,主要步骤如下:检查
ELF可执行文件
格式的有效性,比如魔数、程序头表中段的数量。寻找动态链接的
“.interp”
段,找到动态链接器的路径,以便于后面动态链接时会用上。读取可执行文件的程序头,并且创建
虚拟空间
与可执行文件
的映射关系。初始化
ELF进程环境
。将系统调用的返回地址修改成
ELF可执行文件
的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件
,它就是ELF文件的文件头中e_entry
所指的地址;对于动态链接的ELF可执行文件
,程序入口点就是动态链接器
。
-【将CPU指令寄存器
设置成可执行文件的入口,启动运行】对动态链接来讲,此时就启动了动态链接器。
- 当
load_elf_binary()
执行完毕,返回至do_execve()
在返回至sys_execve()
时,系统调用的返回地址已经被改写成了被装载的ELF
程序的入口地址了。所以,当sys_execve()
系统调用从内核态返回到用户态时,EIP寄存器
直接跳转到ELF程序的入口地址
。此时,ELF可执行文件装载
完成。接下来就是动态链接器对程序进行动态链接了。
2). 为什么要分段、分页
在引入分页和分段之前,操作系统是通过
连续分配方式
来管理存储器,就是说一个进程在内存中是连续存放的。举个例子:内存中有进程1,进程2,进程3
,进程1
先执行完成了,然后释放了所占用的内存空间,之后如果新调入的进程2
内存需求大于之前进程1
所占用的空间,那么不可能利用这块内存,相对于内存需求更大的进程来说,之前进程1
所占用的内存空间就是不能利用的碎片。如果新调入的进程2
内存小于之前之前进程1
所占用的空间就会留下空隙,也会带来碎片。虽然可以通过“紧凑”
的方法进行碎片整理,但开销很大,因此就产生了与连续分配方式
相对的离散分配方式
,便先后引入分页
和分段存储管理
方式。分页存储管理
就是将进程的逻辑地址
空间分成若干大小相等的页,相应地,内存空间也分成若干物理块,页和块大小相等,可将用户程序的任一页放在内存的任一块中,实现离散分配
。
-分段式存储管理
就是用户可以把进程按逻辑关系
划分为若干个大小不等段,每个段都是从0开始
编址,定义一组相对完整的逻辑信息
。存储分配时,以段为单位,段与段在内存中可以不相连接,也实现离散分配
。
分页
和分段
两者都属于存储器管理方式
中的离散分配方式
,都要采用地址映射机构
来实现地址变换。不同点在于:
a.页
是信息的物理单位
,分页是为了实现非连续分配
,以便解决内存碎片
问题,.段
是信息的逻辑单位
,它含有一组意义相对完整的信息,分段的目的是为了更好地实现共享,满足用户的需要;
b.页的大小固定,由系统确定,将逻辑地址
划分为页号
和页内地址
是由机器硬件实现的.而段的长度却不固定,决定于用户所编写的程序,通常由编译程序在对源程序进行编译时根据信息的性质来划分;
c. 分页的作业地址
空间是一维
的.分段的地址空间
是二维
的.
打个比方,比如说你去听课,带了一个纸质笔记本做笔记。笔记本有
100
张纸,课程有语文、数学、英语三门,对于这个笔记本的使用,为了便于以后复习方便,你可以有两种选择。
- 第一种是,你从本子的
第一张纸
开始用,并且事先在本子上做划分:第2张
到第30张纸
记语文笔记,第31到60张纸
记数学笔记,第61到100张纸
记英语笔记,最后在第一张纸
做个列表,记录着三门笔记各自的范围。这就是分段管理
,第一张纸
叫段表
。- 第二种是,你从
第二张纸
开始做笔记,各种课的笔记是连在一起的:第2张纸
是数学,第3张
是语文,第4张
英语……最后呢,你在第一张纸
做了一个目录,记录着语文笔记在第3、7、14、15张纸
……,数学笔记
在第2、6、8、9、11……
,英语
笔记在第4、5、12……
。这就是分页管理
,第一张
纸叫页表
。你要复习哪一门课,就到页表里查寻相关的纸的编号,然后翻到那一页去复习
3)页错误是什么
页错误
又叫页缺失
,在引入分页机制
的操作系统中,一个进程的代码和数据被放置在一个虚拟的地址空间
中,地址空间按固定长度
划分为好多页。同时,物理内存
也按固定长度
划分为好多帧,因为物理内存小
而硬盘空间大
,为了在内存里放置更多的进程,操作系统的设计者决定把页映射
到内存帧
或硬盘的虚拟内存文件中。
进程的可视范围
是它自己的地址空间
,它并不知道某一页映射到内存里还是硬盘上,进程只管自己运行。当进程需要访问某一页时,操作系统
通过查看分页表
,得知被请求的页在内存里还是硬盘里。若在内存里,则进行地址翻译;若在硬盘里,则发生页缺失。操作系统立即阻塞该进程,将硬盘里对应的页换入内存,然后使该进程就绪(可以继续运行)。
7. 进程的内存格局是怎样的?(堆、栈、全局/静态区,代码区,常量区)
内核态内存空间 (Kernel space)
: 供内核使用,其大小一般比较固定,但32 位系统
和64 位系统
的值不一样。栈(stack)
:用于存储局部变量
和函数参数
,由编译器
自动分配和释放。调用一个方法或函数会将一个新的栈桢(stack frame)
压入栈中。栈桢在函数返回时被清理。另外,持续的重用栈空间有助于使活跃的栈内存保持在CPU缓存中,从而加速访问。进程中的每一个线程都有属于自己的栈。内存映射段(Memory Mapping Segment)
:内核将文件的内容直接映射到内存。内存映射是一种方便高效的文件I/O 方式
,所以它被用于加载动态库。创建一个不对应于任何文件的匿名内存映射也是可能的,此方法用于存放程序的数据。在Linux
中,如果你通过malloc
请求一大块内存,C 运行库
将会创建这样一个匿名映射
而不是使用堆内存
。‘大块’
意味着比MMAP_THRESHOLD
还大,缺省是128KB
,可以通过mallopt
调整。
堆(heap)
:用于运行时内存分配,一般由程序员分配和释放,若程序员不释放,程序结束时可能由操作系统释放。该区存放由new、malloc
产生的动态数据。BSS 段()
: 保存的是未被初始化
的静态变量
和全局变量
,它们的值不是直接在程序的源代码中设定的。BSS内存区域
是匿名
的:它不映射到任何文件。如果你写static int cntActiveUsers
,则cntActiveUsers
的内容就会保存在BSS
中。数据段(Data segment)
: 存放已经初始化过的静态变量
和全局变量
,这个内存区域不是匿名的。它映射了一部分的程序二进制镜像
,也就是源代码中指定了初始值的静态变量。所以,如果你写static int cntWorkerBees = 10
,则cntWorkerBees
的内容就保存在数据段中
了,而且初始值为10
。代码段(Text segment)
: 用来存放可执行文件的操作指令
,也就是说是它是可执行程序
在内存种的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作
,而不允许写入(修改)操作——它是不可写的。
8. 堆和栈的区别,函数调用和栈的关系
1)堆和栈的区别:
a.申请方式:
-栈
: 由系统自动分配, 例如,在函数中声明一个局部变量 int b
; 系统自动在栈中为b
开辟空间 。
-
堆
: 需要程序员自己申请,并指明大小。 例如: 在c
中malloc函数
,如p1 = (char*)malloc(10)
;在C++
中用new
运算符如p2 = new char[10]
;
注意:p1和p2本身是在栈中的
b.申请后系统的响应:
栈:只要
栈的剩余空间
大于所申请空间
,系统将为程序提供内存,否则将报异常提示栈溢 出
堆:首先应该知道操作系统有一个
记录空闲内存地址的链表
,当系统收到程序的申请时,
会遍历该链表,寻找第一个空间
大于所申请空间的堆结点
,然后将该结点
从空闲结点链表
中删除,并将该结点的空间
分配给程序,另外,对于大多数系统
,会在这块内存空间中的
首地址
处记录本次分配的大小,这样,代码中的delete
语句才能正确的释放本内存空间。
另外,由于找到的堆结点的大小
不一定正好等于申请的大小
,系统会自动的将多余的那部
分重新放入空闲链表
中。
c.申请大小限制:
栈:在Windows
下,栈
是向低地址
扩展的数据结构
,是一块连续的内存
的区域。这句话的意
思是栈顶的地址
和栈的最大容量
是系统预先规定好的,在WINDOWS
下,栈的大小
是2M
(也有
的说是1M
,总之是一个编译时
就确定的常数),如果申请的空间
超过栈的剩余空间
时,将
提示overflow
。因此,能从栈
获得的空间较小。
堆:堆
是向高地址
扩展的数据结构
,是不连续的内存区域
。这是由于系统是用链表
来存储
的空闲内存地址
的,自然是不连续
的,而链表
的遍历方向
是由低地址
向高地址
。堆的大小
受限于计算机系统
中有效的虚拟内存
。由此可见,堆
获得的空间
比较灵活,也比较大。
d.申请效率比较:
- 栈: 由
系统自动分配
,速度较快。但程序员是无法控制的。 - 堆: 是由
new分配
的内存,一般速度比较慢,而且容易产生内存碎片
,不过用起来最方便.
另外,在WINDOWS
下,最好的方式是用VirtualAlloc
分配内存,他不是在堆,也不是在栈是
直接在进程的地址空间中保留一块内存,虽然用起来最不方便。但是速度快,也最灵活。
e. 存储内容
栈: 在
函数调用
时,第一个进栈
的是主函数
中后的下一条指令
(函数调用语句的下一条可 执行语句
)的地址,然后是函数的
各个参数,在大多数的C编译器
中,参数是由右往左
入栈
的,然后是函数中的局部变量
。注意静态变量
是不入栈的。
当本次函数调用结束后,局部变量
先出栈,然后是参数
,最后栈顶指针
指向最开始存的地
址,也就是主函数中的下一条指令
,程序由该点继续运行。堆:一般是在堆的头部用
一个字节存放堆的大小
。堆中的具体内容由程序员安排。
f. 存取效率:
- 栈: 快
- 堆: 慢
总结堆和栈的区别可以用如下的比喻:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就 走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作
,他的好处是快捷
,但是自 由度小
。
使用堆就象是自己动手做喜欢吃的菜肴
,比较麻烦,但是比较符合自己的口味,而且自由 度大
。
9. 进程和线程的区别
进程:具有一定
独立功能
的程序关于某个数据集合
上的一次运行活动
,进程是系统进行资源分配
和调度
的一个独立单位
.线程:进程的一个
执行单元
,是CPU调度
和分派
的基本单位,也被称为称为轻量级进程
。
关联:
- 一个进程可以由多个线程或单个线程组成
- 线程与同属一个进程的其他的线程共享进程所拥有的全部资源。
- 二者均可并发执行。
区别:
地址空间:
进程
拥有独立的地址空间
;线程
共享本进程的地址空间
。资源拥有:
进程
是拥有系统资源
的一个独立单位
,而线程自己基本上不拥有系统资源
,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈
),和其他线程共享本进程
的相关资源如内存、I/O、cpu
等。独立性: 一个进程崩溃后,在
保护模式
下不会对其他进程产生影响,但是一个线程
崩溃整个进程
都死掉。所以多进程
要比多线程
健壮。系统开销: 在进程切换时,涉及到整个当前进程
CPU环境
的保存环境的设置以及新被调度运行的CPU环境
的设置,而线程切换
只需保存和设置少量的寄存器
的内容,并不涉及存储器管理
方面的操作,可见,进程切换
的开销也远大于线程切换
的开销。执行过程: 每个独立的
进程
有一个程序运行
的入口、顺序执行序列
和程序
的出口。但是线程
不能够独立执行,必须依存在应用程序中,由应用程序
提供多个线程
执行控制。
10.异步和同步,串行,并发,并行的区别
-
同步:多个任务情况下,一个
任务A
执行结束,才可以执行另一个任务B
。只存在一个线程。
-
异步:多个任务情况下,一个
任务A
正在执行,同时可以执行另一个任务B
。任务B
不用等待任务A
结束才执行。存在多条线程
。
-
并行: 真正的异步,
多核CUP
可以同时开启多条线程供多个任务同时执行,互补干扰,如上图的并行,其实和异步图例一样。
-
并发: 是一个伪异步。在
单核CUP
中只能有一条线程,但是又想执行多个任务。这个时候,只能在一条线程上不停的切换任务,比如任务A
执行了20%
,任务A
停下来,线程让给任务B
,任务执行了30%
停下,再让任务A
执行。这样我们用的时候,由于CUP
处理速度快,你看起来好像是同时执行,其实不是的,同一时间只会执行单个任务。
串行:是同步线程的实现方式,就是
任务A
执行结束才能开始执行B
,单个线程只能执行一个任务,就如单行道只能行驶一辆车。
11. 多并发任务,仅多线程能加快速度么(不能,会变慢,有线程切换的开销)
多并发任务中,CPU
只有一个,因此分配给进程的CPU资源
是一定的,多线程
只不过是轮流抢占CPU资源
而已,并不会真正提高处理速度,并且由于线程之间的切换
需要一定的开销,因此也会浪费一定的切换时间,这会导致一个任务
采用一个拥有两个线程的进程
执行所需要的时间比一个线程的进程
执行两次所需要的时间要多一些。但多线程
的作用主要在于提高了并发数量
,比如http请求
,如果是单线程
,一次只能接收一个请求,多线程
则可以同时接收多个请求。
12. 多个线程之间可以共享那些数据
进程代码段
进程的
公有数据
(利用这些共享的数据,线程很容易的实现相互之间的通讯)、进程打开的
文件描述符
信号的处理器
进程的当前目录
进程用户ID
与进程组ID
。
13. 进程之间如何通信
进程间通信(IPC,InterProcess Communication
)是指在不同进程之间传播或交换信息。
IPC
的方式通常有管道(包括无名管道和命名管道)
、消息队列
、信号量
、信号
、 共享内存
、套接字( socket )
、远程过程调用:RPC
等。其中 套接字( socket )
和远程过程调用:RPC
支持不同主机上的两个进程IPC
。
管道:
一种半双工的通信方式,数据只能单向流动。既可在程序中使用,也可在shell中使用。
管道的问题在于他们没有名字,只能在具有亲缘关系(父子进程间)的进程间使用。
扩展:管道
由pipe
函数创建,提供一个单向数据流
。当需要一个双向数据流
时,我们必须创建两个管道
,每个方向一个。这也就是全双工管道
的实现原理:由两个半双工管道构成
。命名管道:即
FIFO
命名管道
也是半双工
的通信方式
。提供单向数据流。
克服了管道没有名字的限制,因此允许无亲缘关系
的进程间通信,解决了管道的上述问题。
扩展:
管道
和FIFO
都是使用通常的read
和write
函数访问。
FIFO
由mkfifo函数
创建。FIFO
不同于管道的是,每个FIFO
有一个路径名与之关联,从而允许无亲缘关系
的进程访问同一FIFO
。
FIFO
的真正优势表现在服务器可以是一个长期运行的进程(如守护进程),而且与其客户可以无亲缘关系。信号量:
主要作为进程间以及同一进程内不同线程之间的同步手段
。
进程间通信
处理同步互斥
的机制。信号量
是一个计数器,可以用来控制多个进程对共享资源
的访问。它常作为一种锁机制
,防止某进程正在访问共享资源时,其他进程也访问该资源。信号:
是一种处理异步事件
的方式。
信号
是一种比较复杂的通信方式
,用于通知接收进程
某个事件已经发生。除了用于进程外,还可以发送信号给进程本身
。
信号
和信号量
是不同的,他们虽然都可用来实现同步
和互斥
,但前者是使用信号处理器
来进行的,后者是使用P,V
操作来实现的。消息队列:
消息队列
是消息的链表
,包括Posix消息队列
和systemV消息队列
。
有足够写权限
的进程都可以向队列中添加消息
,有足够读权限
的进程都可以读走队列中的消息。
消息队列
克服了信号
承载信息量少,管道
只能承载无格式字节流
以及缓冲区大小
受限等缺点。
扩展:消息队列
具有随内核
的持续性,这跟管道
和FIFO
不一样。当一个管道
或FIFO
的最后一次关闭发生时,仍在该管道
或FIFO上
的数据将被丢弃。
SystemV消息队列
的问题之一是无法通知
一个进程何时在某个队列
中放置
了一个消息,也就是无法窥探
一个消息。而Posix消息队列
允许异步事件通知,以告知何时有一个消息放置到了某个空消息队列
中。
Posix消息队列
缺失的主要特性是从队列中读出指定优先级消息
的能力。
使用管道
和FIFO
时,为在两个方向上交换数据,需要两个IPC通道
。而使用消息队列
时单个队列就够用,由每个消息的类型
来标识该消息是从客户到服务器还是从服务器到客户。-
共享内存:
是由一个进程
创建,但多个进程
都可以访问的同一块内存空间
。是最快的可用IPC形式
(因为共享内存区
中的单个数据副本
对于共享该内存的所有线程或进程都是可用的)。是针对
其他通信机制
运行效率较低而设计的。往往与其它通信机制,如信号量
结合使用,来达到进程间的同步
和通信
。一般
IPC形式(管道、FIFO、消息队列)
的问题在于,两个进程要交换信息,这些信息必须经由内核传递。而进程间共享内存
时,交换数据就不再涉及内核。这些进程必须协调或同步对该共享内存区的使用。将
共享内存区
用于客户-服务器文件复制
:该共享内存区同时存在
于客户和服务器的地址空间
。
数据只需复制两次:一次从
输入文件
到共享内存区
,另一次从共享内存区
到输出文件
。然而其他
IPC形式(管道、FIFO、消息队列)
则需要四次复制:另外,默认情况下通过
fork
派生的子进程
并不与其父进程
共享内存区。
套接字(socket):
一般的进程间通信机制,可用于不同机器之间的进程间通信。远程过程调用:RPC:
构筑一个应用程序
时,如果所有进程
都运行在同一台主机
,那么可以使用前面的各种IPC方式
;如果进程不在同一个主机上,那就要求进程间使用某种形式的网络通信,RPC
提供了这样一种工具,它属于隐式网络编程
的范畴。
RPC
是用于从一个系统(客户主机)
上某个程序调用另一个系统上(服务器主机
)某个函数的一种方法。
它的调用进程
和被调用进程
可在不同主机上执行,客户和服务器运行在不同主机上,而且过程调用中涉及网络I/O
,对于程序员是透明的。
另外,RPC
也可用于同一主机上的客户和服务器之间,因此也可认为是另一种形式的消息传递。
详见:进程间通信方式
14. 介绍几种锁,他们的用途和区别
互斥锁:
用于保护临界区
,确保同一时间只有一个线程访问数据。对共享资源
的访问,先对互斥量
进行加锁,如果互斥量
已经上锁,调用线程会阻塞,直到互斥量
被解锁。在完成了对共享资源
的访问后,要对互斥量
进行解锁。临界区:
每个进程中访问临界资源
的那段程序称为临界区
,每次只允许一个进程进入临界区
,进入后不允许其他进程进入。自旋锁:
与互斥量
类似,它不是通过休眠使进程阻塞,而是在获取锁之前一直处于循环检测保持者是否已经释放了锁(自旋)的状态
。用在以下情况:锁持有的时间短,而且线程并不希望在重新调度上花太多的成本。自旋锁
与互斥锁
的区别:线程在申请自旋锁
的时候,线程不会被挂起,而是处于忙等的状态。信号量:
信号量
是一个计数器,可以用来控制多个进程对共享资源
的访问。它常作为一种锁机制
,防止某进程正在访问共享资源
时,其他进程也访问该资源。因此,主要作为进程间
以及同一进程内
不同线程之间的同步手段
。读写锁(rwlock):
高级别锁
,区分读和写,符合条件时允许多个线程
访问对象。处于读锁操作
时可以允许其他线程
和本线程
的读锁
, 但不允许写锁
, 处于写锁
时则任何锁操作
都会睡眠等待;常见的操作系统
会在写锁
等待时屏蔽后续的读锁操作
以防写锁
被无限孤立而等待,在操作系统
不支持情况下可以用引用计数
加写优先等待来用互斥锁
实现。读写锁
适用于大量读少量写
的环境,但由于其特殊的逻辑使得其效率相对普通的互斥锁
和自旋锁
要慢一个数量级;值得注意的一点是按POSIX标准
在线程
申请读锁
并未释放前本线程
申请写锁是成功的,但运行后的逻辑结果是
无法预测`递归锁(recursivelock):
严格上讲递归锁
只是互斥锁
的一个特例,同样只能有一个线程访问该对象,但允许同一个线程在未释放其拥有的锁时反复对该锁进行加锁操作;windows
下的临界区默认是支持递归锁的,而linux
下的互斥量则需要设置参数PTHREAD_MUTEX_RECURSIVE_NP
,默认则是不支持