type
Post
status
Published
date
Apr 7, 2025
slug
summary
tags
操作系统
面试
硕士
category
技术
icon
password
比较管道,共享内存和消息队列
管道和共享内存是两种进程间通信方式。管道是通过在内核空间开辟一块缓冲区,写进程向缓冲区写入数据而读进程从缓冲区读出数据实现通信的,共享内存是开辟一块内核空间,让两个不同进程各自映射到用户空间实现进程通信的。
管道是单向通信的,而共享内存是双向通信的。
管道只适合少量数据传输,因为缓冲区较小。共享内存分配的空间较大,因此适合较多数据传输。
管道的拷贝开销大,需要进程拷贝将待写的数据拷贝到内核管道,另一个进程从内核管道中拷贝出来。而共享内存通过映射的方式避免陷入内核态,因此只需要由写进程将写入共享内存区,读进程不需要复制就可以从共享内存区读出。
共享内存需要锁机制避免数据竞争。
消息队列是不同进程间约定好消息体格式,发送方将消息体写入内核的消息链表中就返回。接收方将消息体复制出来。由于管道是阻塞的,消息队列是非阻塞的,所以消息队列传输效率更高,适合频繁传输。
mmap文件有哪些刷盘方式
mmap将文件映射到用户的文件映射区,当进程修改文件映射区内容后会产生脏页。一方面,内核会有线程定期写回脏页。另一方面可以主动调用msync刷回指定虚拟内存区域的脏页(可以是同步等待的,也可以是异步即只是通知内核刷盘,而不阻塞的),也可以调用fsync以文件为单位刷回文件所有脏数据和元数据。最后,使用munmap解除映射时内核会将未同步的脏页刷回。
mmap的文件会马上放到物理内存吗,什么时候会放到物理内存
mmap仅分配虚拟空间,但是不会马上将文件复制进内存。只有访问虚拟地址时触发缺页中断才会将文件读入页缓存中,并将虚拟地址映射到页缓存的物理地址上。
如果文件已经在page cache中,因为缺失PTE表项,所以mmap仍需要触发一次page fault。在异常处理流程中先检查page cache然后绑定相应物理地址。
open,read,write,unlink,rmdir,truncate函数执行过程
要打开或者创建一个文件,可以使用open系统调用。open系统调用有3个参数。第一个是文件的路径和文件名,第二个是文件的访问模式和操作,第三个是在创建文件时,定义文件的访问权限。
首先,内核需要解析路径名。解析路径名的方式就是从根目录开始,逐级读取目录文件,并确定下一级目录文件的位置。如果目录较长,这个过程可能会很慢。另外,文件系统提供dentry缓存机制加快解析。如果最近访问过某个文件或目录,就会把该文件对应的目录项缓存在内存中,不会逐目录查找。
其次,open第二个参数需要指定文件打开以后的访问模式,包括只读,只写和读写。如果文件不存在,可以用O_CREAT指定创建一个文件,还可以指定是否要追加写、截断等。O_EXCL和O_CREAT合用,表示如果文件本身存在就返回创建失败。O_TRUNC表示如果文件存在就把长度截断到0。O_APPEND表示文件打开后总是往尾部追加写。
创建一个文件的流程包括:路径解析(已经提到),检查该目录是否有权限创建文件,为文件分配inode结构,将文件的名字和inode号更新到目录中。如果需要写入还要分配数据块并更新元数据索引。如果是direct io或者同步写还需要将数据块落盘。
open第三个参数代表权限。在创建文件的模式下,第三个参数指定了文件的访问权限,包括文件创建者,所在组和其他成员的读取,写入,执行权限。
read和write系统调用负责文件的读写。均有三个参数。第一个参数是文件描述符,第二个是缓冲区指针,第三个是读或写的字节数。
read和write都从文件的读写指针开始,可以通过lseek修改读写指针。lessk的三个参数是文件描述符,偏移量和偏移起始地址。偏移起始地址可以是文件开头,末尾或者当前读写指针。
删除文件用unlink系统调用,参数是文件路径。之所以参数是文件路径,是因为不同的文件可能对应同一个inode,也就是硬链接。如果通过文件描述符找到文件,就不知道具体想要删除哪一个硬链接。当删除最后一个硬链接的时候,文件的空间才会真正释放。删除文件的过程包括从目录中删除该文件的表项,减少硬链接计数。如果是最后一个硬链接,就调用具体文件系统的删除操作。
删除目录用rmdir系统调用,参数是目录路径,因为打开目录不会返回一个整数形式的描述符。只能删除空目录,目录不空就要递归删除。
截断文件是指定一个长度,如果文件超过这个长度就把多余部分删除,如果不足这个长度就用\0填充到这个长度。truncate和ftruncate系统调用可以截断。前者的参数是路径名和长度,后者的参数是文件描述符和长度。
区别:truncate之前无需先打开文件,ftruncate需要在打开文件的情况下截断。truncate可能会收到符号链接攻击,意思是高权限的文件被链接到低权限的链接,如果文件系统不完善,那么低权限的用户可能可以操作高权限的文件。
dentry详细介绍
dentry主要包括文件名和路径到inode的映射。同时dentry在内核中以哈希表和LRU链表的方式存放,从而快速定位到目录项。
page cache加速原理
Linux上会将文件先读入内存,作为page cache映射到进程的虚拟地址空间。page cache就是struct page中的address_space结构体。每个文件会在内核中只有一份page cache。但是可以有多个进程中的struct file指向。
page cache不仅是缓存基于文件的数据页,还用于元数据的缓存。不同的page有不同的操作函数,定义在address_space_operations操作集中。
page cache的数据结构是基数树,用于快速定位某些状态,如脏的页。基数树是一种利用前缀索引快速检索的数据结构,检索复杂度是Ok,k是所有字符串最大长度。page cache的键是页偏移量,使用分层编码压缩的方式减少长度
使用page cache和不使用page cache有什么区别
使用page cache的就是buffer io,不使用page cache的是direct io。他们之间的区别有几点。第一是buffer io的读写性能更好,因为可以在内存中命中,而direct io每次都要访存。第二是buffer io需要占用一定内存存放page cache,但是direct io不需要。第三个是buffer io 不用管理一致性问题和崩溃恢复问题,因为page cache已经实现了这个机制,但是direct io需要自己管理。buffer io适合大多数场景,但是数据库等需要精细控制IO或者是已经实现了自己的缓存的应用就可以跳过page cache。
使用direct IO需要注意什么
首先需要手动管理数据的一致性,因为direct io只是保证数据落盘,但不会保证元数据都落盘,需要频繁调用fsync保证所有的元数据都落盘。其次还需要自己实现崩溃恢复,出错重发等机制,然后为了保证性能还需要自己实现批量写入和预读的策略,自己实现读写缓冲区。最后还需要把写入数据块的大小和文件系统数据块的大小对齐。 使用direct io需要在打开文件的时候设置O_DIRECT标志。
Page Cache的一致性与可靠性
更新page cache上的页就成了脏页。一方面操作系统内核线程定期写回脏页,另一方面用户可以主动调用fsync和sync同步某个文件和整个文件系统的数据。最后内存压力大时会导致回收文件页(丢弃或回写)
3个系统调用:fsync:将fd文件的所有脏数据和脏元数据写回磁盘;fdatasync:将fd文件所有的脏数据和必要的元数据写回磁盘。必要的元数据是对访问文件有关键作用的元数据,比如文件大小。但是文件修改时间就不写回。sync:将整个文件系统的脏数据和脏元数据写回磁盘。
page fault 的过程
page fault主要包括缺页异常和权限异常。缺页异常是页表项不存在,权限异常是页表项存在但是没有操作权限。
触发page fault时,首先这是一个中断,因此要保存用户态上下文,进入内核态,并根据中断向量表找到中断处理函数。然后检查虚拟内存地址属于哪一个vma,如果不属于任何一个vma说明还没有分配,因此终止进程。 根据vma的类型决定缺页处理逻辑:新匿名页缺页则分配零页或新物理页,文件映射缺页则从磁盘加载到page cache。swap页缺页则从swap分区换入被换出的物理页。
对于匿名页的首次读访问,且表项为空,会映射到一个内容全为0的全局只读零页,后续发生写操作会再次触发page fault权限异常,进入写时拷贝流程。这是为了延迟分配物理页,等到要写入才真正分配。
对于匿名页的首次写访问,会分配新物理页并更新表项。
对于文件页的读访问,会从磁盘上读取文件页,并且会用预读机制优化
对于文件页的写访问,如果是私有文件映射,会写时拷贝避免影响其他进程。如果是共享文件映射,会直接修改物理页。
page fault 的预读机制
预读机制是触发文件页的缺页中断时,会从磁盘上读取该文件页以及后续若干文件页。预读窗口的大小与访问模式有关,检测到顺序访问会增加窗口,检测到随机访问会缩小窗口。
一个进程open的文件,进程异常退出了,脏数据会落盘吗
会,因为文件的page cache在内核中独立于进程,进程崩溃了,page cache依然会回写脏数据。但是如果系统也崩溃,如掉电等,则不能保证。
如果脏数据在用户态缓冲区还没有写入内存page cache,则也会丢失。
一个进程在写文件,另一个进程删除该文件,删除会不会成功,为什么,写文件的进程能不能继续写
会成功,可以继续写。
因为每个文件有硬链接计数和进程引用计数。删除文件只是减少硬链接计数,并且从目录中删除该文件的项。只要有进程打开文件,则进程引用计数不为零,数据块仍保留在磁盘中。
写进程可以继续写,因为进程通过文件描述符操作文件,即使没有文件目录也可以写。并且可以正常落盘。只有最后一个进程关闭文件后才会删除。
进程线程协程,协程的实现,优缺点
进程是操作系统资源分配的基本单位,线程是CPU调度的基本单位,协程是用户态的轻量级线程。
进程拥有独立的资源,包括地址空间、文件描述符等。进程之间具有隔离性,不同进程需要进程间通信机制交换数据。进程切换的开销大。
同一进程内的不同线程共享地址空间,不需要专门的通信机制,但是需要锁和信号量机制防止数据竞争。线程切换的开销比进程小。
协程是用户态实现的更轻量的执行单位,由用户态的函数库管理,操作系统不知道协程的存在。协程栈只有KB级别,线程栈有MB级别,所以协程可以实现高并发,切换非常快速,而且不需要进入内核态。协程只在等待IO时主动让出CPU而不是被抢占,这是因为内核不能介入协程的执行过程,无法打断协程执行流,并且如果允许抢占则需在任意位置保存全部状态,开销大。协程在线程内串行执行不需要加锁。
但是协程无法利用多核CPU,因为内核不知道协程的存在,所以无法调度。而且协程的使用和调试复杂,需要函数库支持。
进程适合需要隔离多个任务的场景,线程适合需要共享数据、并发的场景,协程适合高并发的场景。
创建进程线程的函数
创建进程:
使用fork可以创建一个进程,具体是根据父进程复制一个子进程,创建时不为子进程分配资源,而是采用写时拷贝技术,其页表项设置为只读,只有写入时触发page fault分配新页面并复制。
exec系列函数用于替换当前进程的代码和数据,加载并执行新程序,覆盖原进程的代码段和数据段。经常和fork连用,因为fork的写时拷贝技术避免了复制父进程的无用数据。但是保留文件描述符等。
创建线程:
c语言有基于posix接口的pthread库,pthread_create创建线程,pthread_join等待线程结束并回收资源。线程函数调用pthread_exit主动退出。pthread库中还有互斥锁和条件变量。 C++11中有thread库,是基于pthread实现的,用法更简单,创建一个thread对象的时候就启动线程,主线程中调用线程对象的join方法等待线程返回或者调用detach使子线程脱离主线程。有互斥锁和条件变量,C++14提供读写锁。
怎么排查死锁
用户态死锁:
pstack/gdb(C/C++)
对进程执行 pstack 或 gdb -p ,查看各线程的调用栈和锁状态。
top/htop
观察CPU占用率异常的进程,死锁线程可能处于高CPU或持续等待状态
strace
跟踪进程系统调用,发现卡在 futex 或 pthread_mutex_lock 等锁操作
内核态死锁
内核日志:通过 dmesg 查看 WARNING 或 deadlock 关键字
coredump分析:利用 gdb 分析转储文件中的线程堆栈和锁状态
使用Ftrace分析锁竞争路径
操作系统如何保持进程隔离性
操作系统通过虚拟内存机制隔离不同内存,虚拟内存机制使得每个进程只能看到属于自己的连续独占虚拟地址空间,看不到其他进程的虚拟地址空间,所以保证了隔离性。
mmap是否影响进程隔离性
不影响。mmap可以有共享和私有映射。私有映射仍然是隔离的,共享映射是进程间通信方式。
进程线程之间哪些资源共享哪些不共享,线程独有的资源有哪些,为什么独有
线程可以共享地址空间,文件描述符,代码段,数据段、堆、环境变量和用户身份等。
但是每个线程有自己独立的线程id,栈和寄存器。因为线程在并行或并发执行不同任务时用到的局部变量和函数不同,如果栈不独立会导致栈帧被覆盖。
例如,某个线程在栈上分配了局部变量,另一个线程分配了另一个局部变量,假如第一个线程想要释放局部变量,就会导致另一个线程的也释放,因为栈时后进先出的。而堆不会,堆释放了不会立刻归还内存。
不同的线程会嵌套执行多个函数,函数的返回地址需要按顺序压入栈,如果栈不独立会导致函数返回错乱。
vfs四个关键结构体
要使用某种文件系统,必须先将其注册到 VFS 核心。注册文件系统就是将该文件系统类型插入一个 file_systems 单链表中。目的是向 VFS 提供 get_sb 和 kill_sb 回调函数,从而装载或卸载该类型的文件系统。 VFS中的对象都是仅存在于内存的。具体文件系统的对象会落盘,有盘上结构
- 超级块super block存放了已挂载文件系统的元数据和控制信息,主要用来指向 fs 超级块(s_fs_info)以及 fs 提供的超级块操作表(s_op)。因为不同文件系统的操作时不同的,这个VFS超级块就是为了向上层提供统一的接口。文件系统层面的操作一般有分配、删除、写回inode、将超级块写入盘上、锁住文件系统和解锁文件系统(比如文件系统要sync)等。
- inode唯一表示文件,通过inode编号管理,包括操作函数表等。VFS的inode只存在内存中,具体文件系统的inode会落盘。
- 目录项用来避免重复解析文件路径,加快路径解析速度。
- file文件对象表示进程打开的文件示例,包括访问模式、偏移量和操作函数表。
比较LRU和FIFO
LRU和FIFO是两种常见的缓存替换算法。
LRU优先淘汰最久未被使用的数据,基于时间局部性原理,通常使用哈希表和双向链表实现,可以在O1内完成插入查找和删除。数据局部性较好的时候缓存命中率高。
LRU的缺点第一是实现比较复杂,需要跟踪页面的访问情况,第二是对突然切换工作集的情况不适用,因为可能频繁逐出新工作集中的数据(这是LFU)。第三是不适合随机IO,因为会预读失效,第四是不适合顺序扫描大文件,因为这种模式缺乏时间局部性,每个块看起来都是最近使用的,会污染缓存,也就是缓存被新加载但是不会再被使用的数据填满。
FIFO按照进入队列的顺序,淘汰最先进入队列的数据,使用队列实现,O1。实现简单,但是性能比较差,尤其是不符合先入先出假设的时候。并且还有belady异常,也就是队列增大,命中率反而下降。原因是队列深度可能不足以覆盖整个工作集(工作集就是一段时间内频繁访问的页面集合)
LRU适合对性能要求较高的系统,FIFO适合对性能要求不高,资源有限的系统,或者数据访问本身就比较随机的场景。
如何设计LRU
采用数据结构为双向链表和映射unordered_map。需要保存的信息有LRU队列的容量capacity,当前元素数量size和链表的头尾节点。实现的操作有,一是插入,如果待插入的节点已经存在于链表中就更新值并移动到头部。如果没有就在头部新增一个节点。同时如果size大于capacity就需要把尾部逐出。二是访问,如果数据在LRU中就读出来并且移动到头部,如果不在可能就要从别处读取,比如从磁盘中读取。
如何改善LRU
- LRU只记录最近一次访问的时间,可以改成记录最近K次访问情况,比如最近k次访问中至少访问一次。
- 双队列LRU:将缓存分为两个部分,一个是短期的FIFO队列,另一个是长期的LRU。数据第一次进入短期队列,第二次再进入LRU。可以解决一次性数据带来的预读失效和缓存污染
- 多级缓冲队列:设置多个优先级不同的队列,每个队列都是LRU的,数据被访问多次后可提升到优先级更高的队列。
操作系统有哪些进程和线程的调度算法
先来先服务、短作业优先、高响应比优先、时间片轮转、高优先级、多级反馈队列
先来先服务:先来先服务是最简单的调度算法,原理就是将作业按照提交的顺序假如队列,每次从队列的头部取出作业执行。优点:实现简单;缺点:对长作业有利,导致短作业被长作业卡住,响应慢。适合CPU密集型的任务,因为可以不被干扰一直运行,不适合IO密集型的任务,因为IO经常要等待,就要重新排队。
短作业优先算法:短作业优先是指每次尽量选择执行时间较短的作业。优点:对短作业友好,不会被长作业卡住;缺点:可能导致长作业饥饿
高响应比优先算法:根据服务时间和等待时间综合计算优先级,服务时间加等待时间除以服务时间。优点:兼顾长短作业,对于两种作业来说,都是等待时间越长优先级越高,对于短作业来说服务时间短,所以优先级略高于长作业。这种基于优先级的算法都可以是抢占式也可以是非抢占式。随着运行可能出现优先级更高的其他作业。
时间片轮转算法:给每个作业分配时间片,每个作业只能运行一定时间片,然后要重新排队。优点:实现简单,公平;缺点:时间片不好选择,时间片太短导致频繁切换,时间片太长退化成先来先服务。
高优先级算法:给每个作业分配优先级,每次执行高优先级的作业。优先级可以是静态的也可以是动态的。优点:保证高优先级的作业及时响应;缺点:导致低优先级作业饥饿。
多级反馈队列算法:设置多个优先级队列,优先级越低的队列时间片越长。将第一次调度的作业放到最高优先级的尾部,如果时间片用完了,就移动到下一级队列。每次只有上一个优先级的队列空才会执行下一个队列的作业。优点:兼顾长短作业,长作业虽然优先级低,但是时间片也长;缺点:仍然可能导致长作业饥饿。并且实现复杂。改进:定期将低优先级队列的内容加回高优先级队列。
Linux块层如何调度IO请求
上层应用调用read或者write发送IO请求,在文件系统中会封装成bio的形式发给块层。块层会将bio合并转化为一个或多个request,并将request插入对应块设备的请求队列中。每个块设备都有自己的请求队列。请求队列会采用一定调度算法进行排序。比如可以先来先服务,也就是不做调度,还可以用deadline机制,也就是排队时间越长优先级越高。
多队列机制:暂时说不上来
Linux的IO栈
首先是一个用户态的程序调用read write等涉及IO的系统调用,然后是进入内核态,由虚拟文件系统VFS将请求交给具体的文件系统处理。文件系统将请求封装成bio发给块层,块层经过调度之后发给设备驱动,设备驱动 再发给设备。
不同的线程在不同的时间通过page cache去写文件,而这两个IO离得比较近,怎样去做调度
没看懂,应该是要做磁盘调度算法
磁盘调度算法
磁盘调度算法的目的是尽量减少磁头移动的距离,减少寻道时间。
先来先服务:这种算法按照IO请求的到来顺序访问磁盘块。当大量进程IO到来的时候可能会导致访问的磁道很分散,寻道时间长。
最短寻道时间优先:先选择离当前磁头最近的位置。问题在于可能导致位置较远的IO饥饿。
扫描算法或者电梯算法:磁头在一个方向上移动,直到到达这个方向上磁道的末尾,然后再换方向。优点是性能较好,不会饥饿,但是缺点是中间的磁道被扫描的频率比两边高。
循环扫描算法:就是单向的电梯算法,只有一个方向是扫描方向,当一次扫描到头以后就回到起点重新扫描。回来的过程不处理IO。优点:每个磁道被访问的机会比较平均;缺点:请求扫描完不会立刻停止,而是扫描到最后一个磁道。
LOOK算法和CLOOK算法:就是针对扫描算法和循环扫描算法的优化,扫描到一个方向的最远请求就回头。CLOOK的缺点:如果一个方向一直到达请求,那么就一直无法回头,导致另一个方向饥饿
deadline算法:为每个请求设置截止时间,优先服务快到截止时间的请求。优点:可以防止饥饿。缺点:需要额外的资源管理请求的截止时间和更新状态。
对于SSD来说没必要做磁盘调度优化,因为SSD不需要寻道。
在文件系统中创建一个文件的流程,如果创建的目录比较长会有什么问题
首先是解析路径,内核需要逐层解析路径的每一个目录,直到找到目标目录。
然后是检查是否有在该目录下创建文件的权限
再分配文件的inode
再将文件的名称和inode编号更新到目录文件中
如果文件需要写入,还需要为文件分配实际的数据块。把数据块先放在page cache中,然后异步刷新到磁盘。
如果目录比较大,可能路径解析的过程比较长。因为需要遍历更多的目录条目。我们可以用符号链接只想较深层次的目录,方便快速访问
怎样保证文件系统写入数据的一致性和原子性
文件系统写入的一致性是指文件系统在任何时候都应该保证逻辑上的一致状态,比如文件的创建时间应该早于修改时间,文件的索引应该是正确的。我们一般讨论的是文件系统的崩溃一致性,比如断电的情况下,有可能导致文件系统的不一致。
为了保证文件系统的一致性,我们可以采用日志的方式。比如在ext4文件系统中有独立的日志区域。文件系统在执行操作之前,会先将要执行的操作提交到日志去,然后再去完成这些操作,最后清除日志。如果文件系统崩溃,那么在重新挂载以后就扫描日志区域,看有没有未完成的事务。
如果文件系统在写日志的时候崩溃,那么数据是否能恢复取决于日志的状态,如果日志中的某个事务没有提交,那么这个事务就不重新应用。只有提交的事务才能重新应用。
ext4提供了几种日志机制,可以保证不同的一致性等级。最差的是writeback机制,这种机制只把元数据的更新写到日志里。那么在上电之后可能会导致元数据指向的数据还没来得及落盘。第二个是ordered机制,他也只把元数据的更新写到日志里,但是必须保证先把数据落盘,然后再写元数据的日志。这样就不会导致元数据指向不存在的数据。第三种是journal机制,就是把数据和元数据都写入journal。这种机制会造成严重的写放大。
除了日志机制以外,还有写时拷贝技术。在文件系统中就是在修改数据的时候,不在原始的地方修改,而是把数据复制到一个新的区域修改,修改完成后再将元数据指向新的位置。比如btrfs和zfs都支持COW。优点是不用重放日志,上电以后就是最新的状态。缺点是导致写放大,因为复制会有开销,并且带来了额外的元数据更新。还会导致磁盘碎片化。
然后还有日志结构文件系统的机制。日志结构文件系统是把整个盘当作一个大日志,在写入的时候也是先写入数据再写入元数据。上电之后再扫描写过的区域重建文件。这种文件系统会定期做检查点来保证一致性。
基于HDD的文件系统和基于SSD的文件系统的区别
基于HDD的文件系统可能采用连续分配来优化大文件的顺序读取。因为HDD更适合顺序IO。连续分配就是尽量给数据块分配连续的地址,这样可以减少寻道时间。但是容易产生外碎片,需要定期做磁盘整理。还可以通过设置预读来减少磁头移动。
然而SSD不用寻道,所以顺序和随机IO都擅长。但是SSD的闪存有读写寿命,所以针对SSD的文件系统需要减少写放大。比如采用日志结构的F2FS文件系统。此外TRIM命令对SSD非常重要,可以告诉SSD哪些数据块不再使用,可以擦除,这样就不用等到写入之前再做GC。
CPU密集型和IO密集型应用特点
CPU密集型应用是指执行流程中大部分时间用于CPU运算(如大规模矩阵计算),IO密集型应用是指执行流程大部分时间用于IO等待(如磁盘寻道或网络传输)
CPU密集型应用的优化可以:采用多核CPU或者GPU并行计算
IO密集型的应用可以采用IO多路复用机制减少IO的监控开销,还可以采用缓存机制减少IO次数。还可以通过协程加速。因为协程的切换开销小,并发性高。
什么是vfork
vfork也是创建一个子进程,但是允许子进程和父进程共享虚拟地址空间,所以可能会导致数据竞争问题。vfork的初衷是避免创建进程时复制地址空间以减少开销,但是现在fork采用写时拷贝技术,不会弄直接复制。因此vfork很少用到。
进程间通信方式
进程通过虚拟地址空间实现了隔离性,因此进程之间的协作需要专门的通信机制。
宏内核进程间通信有管道、消息队列、共享内存、信号量、信号和socket机制。 • 管道:字节流、两个进程、单向、匿名和命名 • 消息队列:消息体、多个进程、单向或双向 • 共享内存:内存区间、多进程、单向或双向 • 信号量:计数器、多进程、单向或双向、同步和互斥 • 信号:事件编号、多进程、单向 • 套接字:数据报文、两个进程、单向或双向、网络栈
管道
管道就是一个单向队列,一端发送一端接收。
有匿名管道和命名管道。 • 匿名管道就是bash命令中的竖线,只允许临时将前面的输出传递给后面的输入。 • 命名管道需要用mkfifo创建,将数据写入命名管道后终端会阻塞,直到另一端被读出。 • 管道通信效率低,不适合进程之间频繁交换数据
匿名管道通过系统调用pipe,在内核中开辟了一块内存缓冲区,并返回两个文件描述符。文件描述符用于读和写缓冲区。
匿名管道只能用于父子进程和子进程之间的通信,这是因为子进程会继承父进程的文件描述符表,从而找到内核缓冲区。
命名管道通过函数mkfifo创建,可以用于不相关进程之间的通信。因为命名管道有一个具体的路径名,可以像文件一样打开。不过实际上并不存在于磁盘,而是一个内存缓冲区。
由于匿名管道和命名管道文件都是先进先出的,所以不支持lseek操作。
管道传递的是无格式的字节流。
消息队列
消息队列是一个保存在内核的消息链表,链表的每一个节点都是一个消息体。消息的发送方和接收方约定好消息体的格式,发送方将数据放在消息队列中就返回,接收方需要时去队列中取得数据,因此是非阻塞的。
消息队列的生命周期随内核,如果不释放消息队列或关闭操作系统,则消息队列一直存在。但是管道的生命周期随进程,引用管道的进程终止则管道消失。
消息队列适合频繁交换数据,因为是非阻塞的。但缺点是不适合大量数据传输,因为消息体有大小限制,并且消息队列在内核,因此有用户态和内核态之间的数据拷贝开销。管道比消息队列更快,因为不需要消息体的封装与解封装。
共享内存
管道和消息队列都要利用内核缓冲区,因此会有拷贝开销。
共享内存机制是将不同进程的虚拟地址空间映射到相同的物理地址空间。优点是不需要用户态和内核态之间的拷贝,缺点是产生数据竞争。
信号量
为了解决数据竞争引入信号量实现进程之间的互斥与同步。
信号量就是一个整形计数器,因此并不能缓存数据,而是表示能进入临界区的进程数量。
信号量有两个原子操作:P是信号量-1,V是信号量+1.P时信号量<0则P阻塞,V后信号量<=0则表示有进程需要唤醒。P和V必须成对使用。
信号量初始化为1则为互斥锁。信号量初始化为0可以实现进程同步:另一个进程执行完后再V,这是当前进程就可以P然后执行。
信号
信号用于单向的事件通知而不是数据传输。信号量也可以通知,但是需要进程主动查询信号量。信号则可以随时通知另一个进程,并且另一个进程不需要阻塞等待,内核会切换到处理函数。
Linux内核为sigint等信号提供了默认处理函数,也可以自己定义信号处理函数。还可以屏蔽信号。但是sigkill等有些信号是不能屏蔽的,因为用于终止一个进程。
信号处理并不是中断处理。因为处理信号的时机一般是进程从内核态返回用户态之前。信号处理函数一般在用户态执行,上下文会保存在用户栈上。如果信号处理函数中有系统调用则再次进入内核态。
信号与中断的联系与区别: • 联系:1. 都是异步通信机制;2. 处理完毕时返回原来的断点;3. 有些中断或信号可以屏蔽 • 区别:1.中断有优先级,信号没有优先级;2.信号处理程序在用户态运行,中断处理程序在内核态运行;3. 中断响应是即时的,而信号响应有一定延迟。
socket
不同主机进程之间通信需要跨网络。
socket创建参数有协议、报文类型。可以实现TCP、UDP和本地进程间通信。 • TCP通信过程 ◦ 服务端通过socket创建套接字,并使用bind将套接字绑定到特定的IP地址和端口上。接着调用listen监听客户端的连接请求。 ◦ 客户端通过socket创建套接字后,调用connect向服务端指定IP地址和端口发起连接请求 ◦ 服务端正在监听该端口并不超过最大连接数,则完成三次握手并建立连接。调用accept返回一个文件描述符代表与客户端的连接并开始数据交换。 ◦ 客户端断开连接则调用close,服务端读取数据时读到EOF,处理完数据后调用close表示连接关闭。 ◦ 注意:监听的socket和建立连接的socket并不一样。建立连接后双方通过read write或send recv向建立连接的socket读写数据。 • UDP通信过程 ◦ 服务端和客户端分别使用socket创建套接字并绑定到某个端口。 ◦ 通信时通过sendto和recvfrom。 ◦ 通信完成后调用close关闭套接字 • 本地socket ◦ 可以是有连接的,也可以是无连接的 ◦ 区别是在socket绑定时绑定一个本地文件系统中的路径作为地址。
aio与io_uring
aio和io_uring是linux中实现异步io的两种框架。io_uring改善了aio存在的一些问题。
- 在aio中,提交io操作和获取io操作结果都需要通过系统调用完成,但是系统调用有切换内核态的开销。
- aio只支持dio,不支持buffer io
- aio中提交和获取io操作接口存在用户态和内核态之间的拷贝
io_uring中在用户态映射了一块共享内存,从而消除了内核态切换的开销。用户进程向共享内存提交需要发起的IO操作,内核线程从共享内存中读取IO操作,并且写入返回结果。
io_uring创建了3块共享内存,分别是提交队列、完成队列和提交队列项数组。这三个队列都是环形的,类似与NVMe协议中的环形命令队列。提交队列存储待执行的IO操作索引,具体的IO操作参数放在提交队列项数组中。完成队列存储操作结果。
操作流程是用户提交SQE,内核处理,结果写入CQE,用户读取CQE。整个过程不需要用户态和内核态之间的转换和拷贝。
如何优化malloc
如果所有的线程都从同一个地方分配内存,会使得竞争激烈,所以可以划分不同的分配区。优化malloc有预分配和线程本地缓存的思路。比如tcmalloc就是每个线程独立维护一个本地缓存,用于快速分配小对象。优点在于线程的本地缓存不需要获取全局锁,线程之间没有竞争,所以速度快。中对象的缓存全局共享,大对象用mmap分配。中和大对象使用自旋锁。
还有jemalloc,将分配区划分更细粒度,每个线程绑定一块区域从而减少全局锁争用。释放空间时还会合并相邻空闲块减少碎片。
tcmalloc小对象分配更快,jemalloc可以控制碎片化,但是内存占用更高
注意:ptmalloc分配时,brk在堆上分配需要加锁,因为线程共用一个堆。mmap在映射区分配空间时,每个线程可以绑定自己的分配区(arena),不需要全局加锁,只需要局部锁。
linux中fsync过程
用户通过fsync(fd)系统调用发起请求,内核检查fd的有效性,获取对应的struct file对象,根据文件系统调用注册的fsync实现函数。
fsync会将文件的脏页从page cache写回磁盘,还会将元数据同步回磁盘
信号量是如何唤醒和阻塞线程的
信号量由一个计数器和等待队列组成。计数器代表当前可用资源的数量,当计数器为正数时,线程可以进入临界区,当计数器为0或负数时,线程需要进入等待队列。等待队列存储因资源不足而被阻塞的线程,调度策略可以是FIFO或按优先级排序的
