操作系统笔记¶
线程是调度的基本单位,进程则是资源分配的基本单位
进程间通信¶
管道 pipe¶
所谓的管道,就是内核⾥⾯的⼀串缓存
管道这种通信⽅式效率低,不适合进程间频繁地交换数据
匿名管道 |
ps auxf | grep mysql
命名管道 FIFO
# 创建命名管道
mkfifo myPipe
mrwang@CodingdeMBP learn % mkfifo myPipe
# linux 一切皆是文件,管道也是文件 类型 p
mrwang@CodingdeMBP learn % ls -l
total 0
prw-r--r-- 1 mrwang staff 0 2 13 14:55 myPipe
mrwang@CodingdeMBP learn %
消息队列¶
消息队列是保存在内核中的消息链表 在发送数据时,会分成⼀个⼀个独⽴的数据单元,也就是消 息体(数据块),消息体是⽤户⾃定义的数据类型,消息的发送⽅和接收⽅要约定好消息体的数据类型,所以每个消息体都是固定⼤⼩的存储块,不像管道是⽆格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息队列⽣命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会⼀直存在,⽽前⾯提到的匿名管道的⽣命周期,是随进程的创建⽽建⽴,随进程的结束⽽销毁。
消息队列不适合⽐较⼤数据的传输,因为在内核中每个消息体都有⼀个最⼤⻓度的限制,同时所有队列所包含的全部消息体的总⻓度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB ,它们以字节为单位,分别定义了⼀条消息的最⼤⻓度和⼀个队列的最⼤⻓度。
消息队列通信过程中,存在⽤户态与内核态之间的数据拷⻉开销,因为进程写⼊数据到内核中的消息队列时,会发⽣从⽤户态拷⻉数据到内核态的过程,同理另⼀进程读取内核中的消息数据时,会发⽣从内核态拷⻉数据到⽤户态的过程。
共享内存¶
共享内存的机制,就是拿出⼀块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写⼊的东⻄,另外⼀个进程⻢上就能看到了,都不需要拷⻉来拷⻉去,传来传去,⼤⼤提⾼了进程间通信的速度。
⽤了共享内存通信⽅式,带来新的问题,那就是如果多个进程同时修改同⼀个共享内存,很有可能就冲突了。例如两个进程都同时写⼀个地址,那先写的那个进程会发现内容被别⼈覆盖了。
信号量¶
为了防⽌多进程竞争共享资源,⽽造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被⼀个进程访问。正好,信号量就实现了这⼀保护机制。
信号量其实是⼀个整型的计数器,主要⽤于实现进程间的互斥与同步,⽽不是⽤于缓存进程间通信的数 据。
信号¶
上⾯说的进程间通信,都是常规状态下的⼯作模式。对于异常情况下的⼯作模式,就需要⽤「信号」的⽅
信号是进程间通信机制中唯⼀的异步通信机制,因为可以在任何时候发送信号给某⼀进程,⼀旦有信号产⽣,我们就有下⾯这⼏种,⽤户进程对信号的处理⽅式 式来通知进程。
1.执⾏默认操作。Linux 对每种信号都规定了默认操作,例如,上⾯列表中的 SIGTERM 信号,就是终⽌进程的意思。
2.捕捉信号。我们可以为信号定义⼀个信号处理函数。当信号发⽣时,我们就执⾏相应的信号处理函数。
3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应⽤进程⽆法捕捉和忽略的,即 SIGKILL 和 SEGSTOP ,它们⽤于在任何时候中断或结束某⼀进程。
Socket¶
前⾯提到的管道、消息队列、共享内存、信号量和信号都是在同⼀台主机上进⾏进程间通信,那要想跨⽹络与不同主机上的进程之间通信,就需要 Socket 通信了。
实际上,Socket 通信不仅可以跨⽹络与不同主机的进程间通信,还可以在同主机上进程间通信。
根据创建 socket 类型的不同,通信的⽅式也就不同:
- 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;
- 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
- 实现 同一台主机上进程间通信: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以AF_UNIX 也属于本地 socket;
本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端⼝,⽽是绑定⼀个本地⽂件,这也就是它们之间的最⼤区别。
总结¶
由于每个进程的⽤户空间都是独⽴的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享⼀个内核空间。
Linux 内核提供了不少进程间通信的⽅式,其中最简单的⽅式就是管道,管道分为「匿名管道」和「命名管道」。
匿名管道顾名思义,它没有名字标识,匿名管道是特殊⽂件只存在于内存,没有存在于⽂件系统中,shell命令中的「 | 」竖线就是匿名管道,通信的数据是⽆格式的流并且⼤⼩受限,通信的⽅式是单向的,数据只能在⼀个⽅向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能⽤于存在⽗⼦关系的进程间通信,匿名管道的⽣命周期随着进程创建⽽建⽴,随着进程终⽌⽽消失。
命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使⽤命名管道的前提,需要在⽂件系统创建⼀个类型为 p 的设备⽂件,那么毫⽆关系的进程就可以通过这个设备⽂件进⾏通信。另外,不管是匿名管道还是命名管道,进程写⼊的数据都是缓存在内核中,另⼀个进程读取数据时候⾃然也是从内核中获取,同时通信数据都遵循先进先出原则,不⽀持 lseek 之类的⽂件定位操作。
消息队列克服了管道通信的数据是⽆格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以⽤户⾃定义的数据类型,发送数据时,会被分成⼀个⼀个独⽴的消息体,当然接收数据时,也要与发送⽅发送的消息体的数据类型保持⼀致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写⼊和读取都需要经过⽤户态与内核态之间的拷⻉过程。
共享内存可以解决消息队列通信中⽤户态与内核态之间数据拷⻉过程带来的开销,它直接分配⼀个共享空间,每个进程都可以直接访问,就像访问进程⾃⼰的空间⼀样快捷⽅便,不需要陷⼊内核态或者系统调⽤,⼤⼤提⾼了通信的速度,享有最快的进程间通信⽅式之名。但是便捷⾼效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。
那么,就需要信号量来保护共享资源,以确保任何时刻只能有⼀个进程访问共享资源,这种⽅式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是⼀个计数器,表示的是资源个数,其值可以通过两个原⼦操作来控制,分别是 P 操作和 V 操作。
与信号量名字很相似的叫信号,它俩名字虽然相似,但功能⼀点⼉都不⼀样。信号是进程间通信机制中唯⼀的异步通信机制,信号可以在应⽤进程和内核之间直接交互,内核也可以利⽤信号来通知⽤户空间的进程发⽣了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),⼀旦有信号发⽣,进程有三种⽅式响应信号 1. 执⾏默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应⽤进程⽆法捕捉和忽略的,即 SIGKILL 和 SEGSTOP ,这是为了⽅便我们能在任何时候结束或停⽌某个进程。
前⾯说到的通信机制,都是⼯作于同⼀台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅⽤于不同的主机进程间通信,还可以⽤于本地主机进程间通信,可根据创建Socket 的类型不同,分为三种常⻅的通信⽅式,⼀个是基于 TCP 协议的通信⽅式,⼀个是基于 UDP 协议的通信⽅式,⼀个是本地进程间通信⽅式。
多线程同步¶
线程之间是可以共享进程的资源,⽐如代码段、堆空间、数据段、打开的⽂件等资源,但每个线程都有⾃⼰独⽴的栈空间。
那么问题就来了,多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。
当获取不到锁时,线程就会⼀直 wile 循环,不做任何事情,所以就被称为「忙等待锁」,也被称为 ⾃旋锁(spin lock)。 那当没获取到锁的时候,就把当前线程放⼊到锁的等待队列,然后执⾏调度程序,把 CPU让给其他线程执⾏,称为 「⽆等待锁」。
锁¶
最底层的两种就是会「互斥锁和⾃旋锁」,有很多⾼级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应⽤。 加锁的⽬的就是保证共享资源在任意时间⾥,只有⼀个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
当已经有⼀个线程加锁后,其他线程加锁则就会失败,互斥锁和⾃旋锁对于加锁失败后的处理⽅式是不⼀样的:
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- ⾃旋锁加锁失败后,线程会忙等待,直到它拿到锁;
互斥锁是⼀种「独占锁」,⽐如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放⼿中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,⾃然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败⽽阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执⾏。如下图:
所以,互斥锁加锁失败时,会从⽤户态陷⼊到内核态,让内核帮我们切换线程,虽然简化了使⽤锁的难度,但是存在⼀定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下⽂切换的成本:
- 当线程加锁失败时,内核会把线程的状态从「运⾏」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运⾏;
- 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把CPU 切换给该线程运⾏。
线程的上下⽂切换的是什么?当两个线程是属于同⼀个进程,因为虚拟内存是共享的,所以在切换时,虚 拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。 上下切换的耗时有⼤佬统计过,⼤概在⼏⼗纳秒到⼏微秒之间,如果你锁住的代码执⾏时间⽐较短,那可 能上下⽂切换的时间都⽐你锁住的代码执⾏时间还要⻓。 所以,如果你能确定被锁住的代码执⾏时间很短,就不应该⽤互斥锁,⽽应该选⽤⾃旋锁,否则使⽤互斥 锁。 ⾃旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「⽤户态」完成加锁和解锁操作,不 会主动产⽣线程上下⽂切换,所以相⽐互斥锁来说,会快⼀些,开销也⼩⼀些。 ⼀般加锁的过程,包含两个步骤: 第⼀步,查看锁的状态,如果锁是空闲的,则执⾏第⼆步; 第⼆步,将锁设置为当前线程持有; CAS 函数就把这两个步骤合并成⼀条硬件级指令,形成原⼦指令,这样就保证了这两个步骤是不可分割 的,要么⼀次性执⾏完两个步骤,要么两个步骤都不执⾏。 使⽤⾃旋锁的时候,当发⽣多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这⾥的 「忙等待」可以⽤ while 循环等待实现,不过最好是使⽤ CPU 提供的 PAUSE 指令来实现「忙等 待」,因为可以减少循环等待时的耗电量。 ⾃旋锁是最⽐较简单的⼀种锁,⼀直⾃旋,利⽤ CPU 周期,直到锁可⽤。需要注意,在单核 CPU 上,需 要抢占式的调度器(即不断通过时钟中断⼀个线程,运⾏其他线程)。否则,⾃旋锁在单 CPU 上⽆法使 ⽤,因为⼀个⾃旋的线程永远不会放弃 CPU。 ⾃旋锁开销少,在多核系统下⼀般不会主动产⽣线程切换,适合异步、协程等在⽤户态切换请求的编程⽅ 式,但如果被锁住的代码执⾏时间过⻓,⾃旋的线程会⻓时间占⽤ CPU 资源,所以⾃旋的时间和被锁住的 代码执⾏的时间是成「正⽐」的关系,我们需要清楚的知道这⼀点。 ⾃旋锁与互斥锁使⽤层⾯⽐较相似,但实现层⾯上完全不同:当加锁失败时,互斥锁⽤「线程切换」来应 对,⾃旋锁则⽤「忙等待」来应对。 它俩是锁的最基本处理⽅式,更⾼级的锁都会选择其中⼀个来实现,⽐如读写锁既可以选择互斥锁实现, 也可以基于⾃旋锁实现。