进程间同步的机制

进程间同步互斥,资源共享与通讯 - 图1

在讲解之前,我们先讨论一个问题: 从需求上来讲,进程内协同与进程间协同有何不同?

在早期, 操作系统只有进程这个唯一的执行体, 今天, 进程内的执行体(线程与协程)被发明出来并蓬勃发展, 事情发生了怎样的变化?

启动进程

在考虑进程协同之前, 我们先看下怎么在一个进程中启动另一个进程. 这通常有两种方法

  • 创建子进程
  • 让Shell配合执行某个动作

Unix系的操作系统使用了 Fork API 使用上很简洁. Windows中使用 CreateProcess, 这个函数有很多参数

IOS不支持创建子进程, 在进程启动上, 它有两个很重要的变化

  • 软件不再创建多个进程实例, 永远是单例的
  • 一个进程要调用另一个进程的能力, 不是去创建它, 而是基于 URL Scheme 去打开它

URL SCheme

什么是URL Scheme? 我们平时看到的URL地址 如:

这里的https和ftp就是URL Scheme, 它代表了某种协议规范, IOS下, 一个软件可以声明自己实现了某种URL Scheme, 比如 微信可能注册了 “weixin” 这个URL Scheme , 那么可以调用

  1. UIApplication.openURL("weixin://...")

都会跳转到微信, 通过这个进制, 我们实现了支付宝和微信支付能力的对接

URL Scheme 机制并非IOS的发明, 它是浏览器出现后的一种扩展机制, win和Linux的桌面也支持类似的能力, 在Win下调用的是ShellExecute函数

同步与互斥

首先我们看一下同步和互斥体

  • 锁(Mutex)
  • 读写锁(RWMutex)
  • 信号量(Samphore)
  • 等待组(WaitGroup) (进程间无)
  • 条件变量(Cond) (进程间无)

进程间协同来说, 主流操作系统支持了锁(Mutex)和信号量(Semphore), Win还额外支持了事件(Event)同步原语.

进程间的锁(Mutex) , 语义上和进程内没有区别, 只不过标识互斥资源的方法不同, Win最简单, 用名称(Name)标识资源, IOS用(Path), Linux则用共享内存

信号量

信号量(Semaphore)概念是Dijkstra(最短路径算法的发明人)提出的, 信号量本身是一个整型数值, 代表某种共享资源的数量(简记为S) . 信号量的操作界面为PV操作

P操作意味着请求或等待资源, 执行P操作P(S)时, S的值-1, 如果S<0, 说明没有资源可用, 等待其他执行体释放资源

V操作意味着释放资源并唤醒执行体, 执行V操作V(S)时, S的值 +1, 如果S<=0 则意味着有其他执行体在等待中, 唤醒其中的一个

条件变量的设计也是从信号量的PV操作进一步抽象而来, 只不过信号量中的变量是确定的, 条件也是确定的

进程间的同步与互斥原语没有进程内那么丰富(没有WaitGroup, Cond) 也没有那么牢靠

缺陷

进程可能会异常挂掉, 这会导致同步和异步的状态发生异常, 比如, 进程获得了锁, 但是在任务过程中挂掉, 这会导致锁没有得到正常的释放, 那么另一个等待该锁的进程就会永远饥饿

信号量(system V) 有一个属性是un-do,如果进程挂掉,这个进程获得的资源会释放。避免死锁饿死的问题

信号量也有这样的问题, 并且会更麻烦. 进程挂掉还可以把释放锁的任务交给内核处理, 信号量则不行, 应为操作系统不知道信号量的值(S) 应该是多少才合理

资源共享

进程不论如何被隔离, 只要有共同的中间人, 就可以相互对话(通讯). 中间人可以是谁? 共享资源.

进程之间都有哪些共享的存储型资源?

  • 文件系统
  • 剪贴板
  • 网络

文件系统本身是因设备的管理而来. 因为存储设备本身天然是共享资源, 某个进程在存储设备上创建一个文件或目录, 其他进程自然可以访问到.

因此, 文件系统天然是一个进程间通信的中间人. 而且, 在很多操作系统里面, 文件的概念被抽象话, “一切皆文件”. 比如, 命名管道就是一种特殊的”文件”

和文件系统相关的进程间协同机制有:

  • 文件
  • 文件锁
  • 管道(包括匿名和命名)
  • 共享内存

我们这里重点介绍一些共享内存

共享内存其实是虚拟内存机制的自然结果, 虚拟内存本来就需要在内存页与磁盘文件之间进行数据的保存于恢复

将虚拟内存的内存页和磁盘文件的内容建立映射关系, 在虚拟内存管理机制中原本就存在

只需要让两个进程的内存页关联到同一个文件句柄, 即可完成进程间的数据共享, 这可能是性能最高的进程间数据通讯手段

Linux的内存共享的使用界面大体如下

  1. func Map(addr unsafe.Pointer, len int64, prot, flags int, fd int, off int64) unsafe.Pointer
  2. func Unmap(addr unsafe.Pointer, len int64)
  • Map 将文件fd中的 [off, off+len) 区间的数据映射到[addr, addr+len)这段虚拟地址上, addr 可以传入nil表示一段空闲的虚拟内存地址空间来进行映射
  • Unmap 将[addr, addr+len) 这段虚拟内存对应的内存页取消映射, 此后如果代码中还对这段内存地址进行访问, 就会发生缺页异常

IOS的不同

基于文件系统的进程间通信机制一律不支持. 为什么?

因为IOS操作系统做了一个极大的改变: 软件被装到了一个沙箱(SandBox)里, 不同进程间的存储完全被隔离

存储分为内存和外存. 内存通过虚拟内存机制实现跨进程的隔离, IOS则更进一步, 外存的文件系统也相互独立, 软件A创建的文件, 软件B默认情况下并不能访问. 在软件进程看来, 自己独享整个外存的文件系统

剪切板

不稳定, 因为只要有人推数据上去, 新的数据就会覆盖掉旧的数据

真正需要注意的是, 剪切板被一些别有用心的程序监听, 窃取用户剪贴板的数据 (国内某些厂商堪比病毒)

网络

所有的进程都在同一台机器上, 他们在同一个局域网中

套接字作为网络通信的抽象, 本身就是最强大的通信方式, 进程间基于套接字通讯, 也是极其自然的一个选择

UNIX还发明了一个专门用于本地通信的套接字:UNIX域 不同于常规套接字的是, 他通过一个name来作为访问地址, 而不是 ip:port来作为访问地址

Win平台不支持Unix域, 但是Win的命名管道也不是一个常规意义上的管道, 它更像是一个管道服务器, 一个客户端上来可以分配一个独立的管道给服务器和客户端进行通信. 这样来看, Win的命名管道和UNIX域在能力上是等价的

这里暂时不展开对套接字的详细解释

架构思维上我们学习到什么?

对比不同操作系统的进程间协同机制, 差异是非常巨大的

:::tips 当然我认为拿 移动设备操作系统 和 桌面操作系统比不太合适

他们的场景有巨大的差异

:::

IOS和Linux,Win比, 做了非常多的减法

  • 软件不需要启动多个实例, 一个软件只用启动一个实例实例
  • 大部分软件的协同机制都是多余的, 你只要能够调用到其他软件的能力(URL Scheme) , 能够互斥, 能够收发消息即可

:::tips Unix和Win诞生的时代 浏览器还没有诞生,还没有 URL Scheme

:::

并非是早期的操作系统设计者们的设计完全有问题. 而是随着计算机发展, 有了线程和协程这样的进程内任务设施滞后, 进程的边界发生了极大的变化

需求分析滞之后, 我们要做概要设计, 概要设计的核心就是做 子系统的划分

  • 子系统的职责范围定义
  • 子系统的规格(接口), 子系统与子系统之间的边界
  • 需求分解和组合的过程, 系统如何满足需求, 需求适用性(变化点)的应对策略

从架构角度来看,进程至少应该是子系统级别的边界。子系统和子系统应该尽可能是规格级别的协同,而不是某种实现框架级别的协同。规格强调的是自然体现需求,所以规格是稳定的,是子系统的契约。而实现框架是技巧,是不稳定的,也许下次重构的时候实现框架就改变了。

IOS如此设计的另一个重要原因就是 **<font style="color:#F5222D;">安全问题</font>** 后面我们会弹到这个问题