Ahab's Studio.

细读《深入理解 Android 内核设计思想》(一)进程间通信与同步机制

字数统计: 3.4k阅读时长: 12 min
2020/04/01 Share

对冗余挑拣重点,对重点深入补充,输出结构清晰的精简版

  1. 进程间通信的经典实现
    1. 共享内存
    2. 管道
    3. UNIX Domain Socket
    4. Remote Procedure Calls
  2. 同步机制的经典实现
    1. 信号量
    2. Mutex
    3. 管程
    4. Linux Futex
  3. Android 中的进程间同步机制
    1. 进程间同步 Mutex
    2. 条件判断 Condition
    3. 加解锁的自动化操作 Autolock
    4. Mutex+Autolock+Condition 示例
  4. 最后

进程间通信的经典实现

进程间通信(Inter-process communication,IPC)指运行在不同进程中的若干线程间的数据交换,可发生在同一台机器上,也可通过网络跨机器实现,以下几种因高效稳定的优点几乎被应用在所有操作系统中,分别是共享内存、管道、UNIX Domain Socket 和 RPC 。

共享内存

共享内存是一种常用的进程间通信机制,不同进程可以直接共享访问同一块内存区域,避免了数据拷贝,速度较快。实现步骤:

  1. 创建内存共享区
    Linux 通过 shmget 方法创建与特定 key 关联的共享内存块:

    1
    2
    //返回共享内存块的唯一 Id 标识
    int shmget(key_t key, size_t size, int shmflg);
  2. 映射内存共享区
    Linux 通过 shmat 方法将某内存块与当前进程某内存地址映射

    1
    2
    //成功返回指向共享存储段的指针 
    void *shmat(int shm_id, const void *shm_addr, int shmflg);
  3. 访问内存共享区
    其他进程要访问一个已存在的内存共享区的话,可以通过 key 调用 shmget 获取到共享内存块 Id,然后调用 shmat 方法映射

  4. 进程间通信
    当两个进程都实现对同一块内存共享区做映射后,就可以利用此内存共享区进行数据交换,但要自己实现同步机制
  5. 撤销内存映射
    进程间通信结束后,各个进程需要撤销之前的映射,Linux 可以调用 shmdt 方法撤销映射:

    1
    2
    //成功则返回 0,否则出错
    int shmdt(const void *shmaddr);
  6. 删除内存共享区
    最后需要删除内存共享区,以便回收内存,Linux 可以调用 shctl 进行删除:

    1
    2
    //成功则返回 0,否则出错,删除操作 cmd 需传 IPC_RMID
    int shmctl(int shm_id, int cmd, struct shmid_ds *buf);

shmget 方法名言简意赅,share memory get !其中 get 还有一层含义,为什么不叫 create 呢?之前如果创建过某一 key 的共享内存块,再次调用便直接返回该内存块,不会发生创建操作了。

管道

管道(Pipe)是操作系统中常见的一种进程间通信方式,一根管道有”读取”和”写入”两端,读、写操作和普通文件操作类似,并且是单向的。管道有容量限制,当写满时,写操作会被阻塞;为空时读操作会被阻塞。

Linux 通过 pipe 方法打开一个管道:

1
2
//pipe_fd[0] 代表读端,pipe_fd[1] 代表写端,
int pipe(int pipe_fd[2], int flags);

以上方式只能用于父子进程,因为只有一个进程中定义的 pipe_fd 文件描述符只有通过 fork 方式才能传给另一个进程继承获取到,也正是因为这个限制,Named Pipe 得以发展,改变了前者匿名管道的方式,可以在没有任何关系的两个进程间使用。

UNIX Domain Socket

UNIX Domain Socket(UDS)是专门针对单机内的进程间通信,也称 IPC Socket,与 Network Socket 使用方法基本一致,但实现原理区别很大:

  • Network Socket 基于 TCP/IP 协议,通过 IP 地址或端口号进行跨进程通信
  • UDS 基于本机 socket 文件,不需要经过网络协议栈,不需要打包拆包、计算校验等

Android 中使用最多的 IPC 是 Binder,其次就是 UDS。

Remote Procedure Calls

RPC 即远程过程调用(Remote Procedure Call),RPC 是指计算机 A 上的进程,调用另外一台计算机 B 上的进程,其中 A 上的调用进程被挂起,而 B 上的被调用进程开始执行,当值返回给 A 时,A 进程继续执行。调用方可以通过使用参数将信息传送给被调用方,而后可以通过传回的结果得到信息。

Java RMI 就是一种 RPC 框架,指的是远程方法调用 (Remote Method Invocation)。它能够让一个 Java 虚拟机上的对象调用另一个 Java 虚拟机中的对象的方法。

RPC 可以理解为一种编程模型,就像 IPC 一样,比如我们常说 Android AIDL 是一种 IPC 实现方式,也可以称为一种 RPC 方式。

同步机制的经典实现

信号量

信号量与 PV 原语操作是一种广泛使用的实现进程/线程互斥与同步的有效方法,Semaphore S 信号量用于指示共享资源的可用数量。
P 操作:

  1. S = S - 1
  2. 然后判断若 S 大于等于 0,代表共享资源允许访问,进程继续执行
  3. 若 S 小于 0,代表共享资源被占用,需等待别人主动释放资源,该进程阻塞放入等待该信号量的队列中,等待被唤醒

V 操作:

  1. S = S + 1
  2. 然后判断若 S 大于 0,代表没有正在等待访问该资源的进程,无需处理
  3. 若 S 小于等于 0,从该信号的等待队列中唤醒一个进程

Java 中的信号量的实现类为 Semaphore,P、V 操作分别对应 acquire、release 方法。

Mutex

Mutex 即互斥锁,可以和信号量对比来理解,信号量可以使资源同时被多个线程访问,而互斥锁同时只能被一个线程访问,也就是说,互斥锁相当于一个只允许取值 0 或 1 的信号量。

Java 中 ReentrantLock 就是互斥锁的一种实现。

管程

采用 Semaphore 机制的程序中 P、V 操作大量分散在程序中,代码易读性差,不易管理,容易发生死锁,所以引入了管程 Monitor。

管程把分散在各进程中的临界区集中起来进行管理,防止进程有意或无意的违法同步操作,便于用高级语言来书写程序,也便于程序正确性验证。

管程封装了同步操作,对进程隐蔽了同步细节,简化了同步功能的调用界面。用户编写并发程序如同编写顺序(串行)程序。

Java 中 synchronized 同步代码块就是 Monitor 的一种实现。

Linux Futex

Futex 全称 Fast Userspace muTexes,直译为快速用户空间互斥体,那他比普通的 Mutex 快在哪里呢?

Semaphore 等传统同步机制需要从用户态进入到内核态,通过一个提供了共享状态信息和原子操作的内核对象来完成同步。但大多数场景同步是无竞争的,不需要进入互斥区等待就可以直接获取到锁,但依然进行了内核态的切换操作,这造成了大量的性能开销。

Futex 通过 mmap 让进程间共享一段内存,当进程尝试进入互斥区或退出互斥区的时候,先查看共享内存中的 Futex 变量,如果没有竞争发生,则只修改 Futex 变量而不执行系统调用切换内核态。

Futex 的 Fast 就体现在对于大多数不存在竞争的情况,可以在用户态就完成锁的获取,而不需要进入内核态,从而提高了效率。

如果说 Semaphore 等传统同步机制是一种内核态同步机制,那 Futex 就是一种用户态和内核态混合的同步机制。

Futex 在 Android 中的一个重要应用场景是 ART 虚拟机,如果 Android 版本开启了 ART_USE_FUTEXES 宏,那 ART 虚拟机中的同步机制就会以 Futex 为基石来实现,省略后的关键代码如下:

1
2
3
4
5
6
7
8
9
// art/runtime/base/mutex.cc
void Mutex::ExclusiveLock(Thread* self){
#if ART_USE_FUTEXES
//若开启 Futex 宏就通过 Futex 实现互斥加锁
futex(...)
#else
//否则通过传统 pthread 实现
CHECK_MUTEX_CALL(pthread_mutex_lock,(&mutex_));
}

源码见 http://androidxref.com/7.0.0_r1/xref/art/runtime/base/mutex.cc

Android 中的进程间同步机制

了解了操作系统经典的同步机制后,再来看 Android 中是怎么实现的。

进程间同步 Mutex

Mutex 实现类源码很短,见 http://androidxref.com/7.0.0_r1/xref/system/core/include/utils/Mutex.h

注意这里说的 Mutex 和上面的 mutex.cc 是两个东西,mutex.cc 是 ART 中的实现类,支持 Futex 方式; 而 Mutex.h 只是对 pthread 的 API 进行了简单封装,函数声明和实现都在 Mutex.h 一个文件中。

源码中可以看到一个枚举类型定义:

1
2
3
4
5
6
class Mutex {
public:
enum {
PRIVATE = 0,
SHARED = 1
};

其中 PRIVATE 代表进程内同步,SHARED 代表进程间同步。Mutex 相比 Semaphore 较简单,只有 0 和 1 两种状态,关键方法为:

1
2
3
4
5
6
7
8
9
inline status_t Mutex::lock() {//获取资源锁,可能阻塞等待
return -pthread_mutex_lock(&mMutex);
}
inline void Mutex::unlock() {//释放资源锁
pthread_mutex_unlock(&mMutex);
}
inline status_t Mutex::tryLock() {//获取资源锁,不论成功与否都立即返回
return -pthread_mutex_trylock(&mMutex);
}

当要访问临界资源时,需先通过 lock() 获得资源锁,如果资源可用会此函数会立即返回,否则阻塞等待,直到其他进程(线程)调用 unlock() 释放了资源锁从而被唤醒。

tryLock() 函数存在有什么意义呢?它在资源被占用的情况下,不会像 lock() 一样进入等待,而是立即返回,所以可以用来试探性查询资源锁是否被占用。

加解锁的自动化操作 Autolock

Autolock 为 Mutex.h 中的一个嵌套类,实现如下:

1
2
3
4
5
6
7
8
9
10
// Manages the mutex automatically. It'll be locked when Autolock is
// constructed and released when Autolock goes out of scope.
class Autolock {
public:
inline Autolock(Mutex& mutex) : mLock(mutex) { mLock.lock(); }
inline Autolock(Mutex* mutex) : mLock(*mutex) { mLock.lock(); }
inline ~Autolock() { mLock.unlock(); }
private:
Mutex& mLock;
};

如注释所示,Autolock 会在构造时主动去获取锁,在析构时会自动释放掉锁,也就是说,在生命周期结束时会自动把资源锁释放掉。

这就可以在一个方法开始时为某 Mutex 构造一个 Autolock,当方法执行完后此锁会自动释放,无需再主动调用 unlock,这让 lock/unlock 的配套使用更加简便,不易出错,

条件判断 Condition

条件判断的核心思想是判断某 “条件” 是否满足,满足的话马上返回,否则阻塞等待,直到条件满足时被唤醒。

你可能会疑问,Mutex 不就可以实现吗,干嘛又来一个 Condition,它有什么特别之处?

Mutex 确实可以实现基于条件判断的同步,假如条件是 a 为 0,实现代码会是这样:

1
2
3
4
5
6
7
8
9
10
while(1){
acquire_mutex_lock(a); //获取 a 的互斥锁
if(a==0){
release_mutex_lock(a); //释放锁
break; //条件满足,退出死循环
}else{
release_mutex_lock(a); //释放锁
sleep();//休眠一段时间后继续循环
}
}

什么时候满足 a==0 是未知的,可能是很久之后,但上面方式无限循环去判断条件,极大浪费 CPU。

而条件判断不需要死循环,可以在满足条件时才去通知唤醒等待者。

Condition 源码见 http://androidxref.com/7.0.0_r1/xref/system/core/include/utils/Condition.h ,它和 Mutex 一样也有 PRIVATE、SHARED 类型,PRIVATE 代表进程内同步,SHARED 为进程间同步。关键方法为:

1
2
3
4
5
6
7
8
//在某个条件上等待
status_t wait(Mutex& mutex)
//在某个条件上等待,增加超时机制
status_t waitRelative(Mutex& mutex, nsecs_t reltime)
//条件满足时通知相应等待者
void signal()
//条件满足时通知所有等待者
void broadcast()

Mutex+Autolock+Condition 示例

书中通过 Barrier 呈现 Condition 使用示例,还有一个我们更为熟知的 LinkedBlockingQueue 也很适合,源码见 http://androidxref.com/7.0.0_r1/xref/frameworks/av/media/libstagefright/webm/LinkedBlockingQueue.h。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class LinkedBlockingQueue {
List<T> mList;
Mutex mLock;
Condition mContentAvailableCondition;

T front(bool remove) {
Mutex::Autolock autolock(mLock);
while (mList.empty()) {
mContentAvailableCondition.wait(mLock);
}
T e = *(mList.begin());
if (remove) {
mList.erase(mList.begin());
}
return e;
}
//省略...

void push(T e) {
Mutex::Autolock autolock(mLock);
mList.push_back(e);
mContentAvailableCondition.signal();
}
}

调用 front 方法出队元素时,首先获取 mLock 锁,然后判断若列表为空就调用 wait 方法进入等待状态,待 push 方法入队元素后通过 signal 方法唤醒。

front 方法占有了 mLock 锁,push 方法不应该阻塞在第一行代码无法往下执行吗?

很简单,wait 方法中释放了 mLock 锁,见 pthread_cond.cpp:http://androidxref.com/7.0.0_r1/xref/bionic/libc/bionic/pthread_cond.cpp#173

可以不依赖 Mutex 仅通过 Condition 的 wait/signal 实现吗?

不行,因为对 mList 的访问需要加互斥锁,否则可能出现 signal 无效的情况。比如 A 进程调用 front ,判断 mList 为空,即将执行 wait 方法时,B 进程调用 push 方法并执行完,那么 A 进程将得不到唤醒,尽管此队列中有元素。

最后

书中说到:不论什么样的操作系统,其技术本质都类似,而更多的是把这些核心的理论应用到符合自己需求的场景中。

不知道在讲这句话时,作者脑中一闪而过的,是怎样庞大而深厚的技术栈。

原文作者:Ahab

原文链接:http://yhaowa.gitee.io/12a8b16b/

发表日期:April 1st 2020, 12:50:13 am

更新日期:May 23rd 2020, 10:57:30 am

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. 进程间通信的经典实现
    1. 1.1. 共享内存
    2. 1.2. 管道
    3. 1.3. UNIX Domain Socket
    4. 1.4. Remote Procedure Calls
  2. 2. 同步机制的经典实现
    1. 2.1. 信号量
    2. 2.2. Mutex
    3. 2.3. 管程
    4. 2.4. Linux Futex
  3. 3. Android 中的进程间同步机制
    1. 3.1. 进程间同步 Mutex
    2. 3.2. 加解锁的自动化操作 Autolock
    3. 3.3. 条件判断 Condition
    4. 3.4. Mutex+Autolock+Condition 示例
  4. 4. 最后