Mach 原语:一切以消息为媒介
XNU 的核心是Mach 微内核。 Mach 是 OS X 和 iOS 的核心中的核心。尽管Mach 核心被 BSD 层包装起来了,而且主要的内核接口是标准的POSIX 系统调用,但是这个Mach 核心具有一组独特的API和原语。
Mach 设计原则
Mach 采用的是极简主义的概念:具有一个简单最小的核心,支持面向对象的模型,使得独立的具有良好定义的组件(实际上就是子系统)可以通过消息的方式互相通讯。在Mach 中,所有的东西都是通过自己的对象实现的。进程(在Mach 中称为任务)、线程、虚拟内存都是对象,所有对象都有自己的属性。其实对象就是C语言结构体加上函数指针。Mach 的独特之处在于选择了通过消息传递的方式实现对象和对象之间的通信。XNU 的“官方”API 是BSD 的POSIX API,苹果保持Mach绝对的极简。由于外层具有非常丰富的Cocoa API,所以很多开发者都根本意识不到Mach的存在。不过,Mach调用仍然是整个架构中最基础的部分。
Mach 设计目标
Mach的手机文档列出了一些设计目标,其中首要目标就是将所有功能移出内核,并放在用户态中,将内核保持在极简的状态:
- “控制点”或执行单元(线程)管理
- 线程或线程组(任务)的资源分配
- 虚拟内存分配和管理
- 底层物理资源:即CPU、内存和任何其他物理设备的分配
Mach 消息
Mac 中最基本的概念就是消息了,消息在两个端点(endpoint)或端口(port)之间传递。消息是Mach IPC 的核心构建块。Mach 消息的设计考虑了参数串行话、对齐、填充(padding,为了对齐)和字节顺序的问题。
发送消息
Mach 消息的发送和接收都是通过同一个API函数 mach_msg( )进行的。这个函数在用户态和内核中都有实现的。Mach 消息原本是为真正的微内核架构而设计的。也就是说,mach_msg( )函数必须在发送者和接收者之间复制消息所在的内存。尽管这种实现忠实于微内核的范式,但是事实证明频繁内存复制操作带来的性能损耗是不能忍受的,因此,XNU 算是通过单一内核的方式“作弊”:所有的内核组件都共享一个地址空间,因此消息传递只需要传递消息的指针就可以了,从而省去了昂贵的内存复制操作。
为了实现消息的发送和接收,mach_msg( ) 函数调用了一个Mach 陷阱(trap)。Mach 陷阱就是和系统调用的概念,在用户态调用mach_msg_trap( ) 会引发陷阱机制,切换到内核态,在内核态中,内核实现的mach_msg( ) 会完成实际的工作。
端口
消息在端点(也称为端口)之间传递,端口只不过是32为整型的标识符。所有的mach 原生对象都是通过对于的端口访问的。也就是说,查找一个对象的句柄(handle)时,实际上请求的是这个对象端口的句柄。
深入IPC
IPC 所需要的基本原语:消息、发送和接收消息的端口,以及确保安全并发的信号量和锁。每一个Mach 任务(进程的高级抽象)包含一个指针指向自己的IPC 名称空间,在名称空间中保存了自己的端口。此外,任务也可以获得系统范围内的端口,例如主机端口、特权端口和其他端口。导出给用户空间的端口对象实际上是对“真正”端口对象的一个句柄。
消息传递的实现
用户态的Mach消息传递使用mach_msg( )函数。这个函数通过内核的Mach 陷阱机制调用内核函数mach_msg_trap( ) 。然后mach_msg_trap( )调用 mach_msg_overwrite_trap( ),mach_msg_overwrite_trap( ) 通过测试MACH_SEND_MSG和MACH_REV_MSG标志位来判断发送操作还是接收操作
发送消息
Mach 消息发送的逻辑在内核中的两处实现:Mach_msg_overwrite_trap( ) 和 mach_msg_send( )。后者只用于内核态的消息传递,在用户态不可见。
两种情形的逻辑都差不多,遵循以下的流程:
- 调用current_space( ) 获得当前的IPC空间
- 调用current_map( ) 获得的当前的VM空间(vm_map)
- 对消息的大小进行正确性检查
- 计算要分配的消息大小:从send_size参数获得大小,然后加上硬编码的MAX_REAILER_SIZE
- 通过ipc_kmsg_alloc 分配消息
- 复制消息(复制消息send_size字节的部分),然后在消息头设置msgh_size
- 复制消息关联的端口权限,然后通过ipc_kmsg_copyin 将所有out-of-line 数据内存复制到当前的vm_map。ipc_kmsg_copyin 函数调用了ipc_kmsg_copyin_header 和 ipc_kmsg_copyin_body
- 调用ipc_kmsg_send( )发送消息:
- 首先,获得msgh_remote_port 引用,并锁定端口
- 如果端口是一个内核端口(即端口的ip_receiver是内核IPC空间),那么通过ipc_kobject_server( ) 函数处理消息。这个函数会在内核中找到相应的函数来执行消息(或者调用ipc_kobject_notify( )来执行),而且一个会生成消息的应答。
- 不论是哪种端口:也就是说如果端口不在内核空间中,或者从ipc_kobjct_server( ) 返回了应答,这个函数会贯穿到传递消息(或应答消息)的部分,调用ipc_mqueue_send( ),这个函数将消息直接复制到端口的ip_messgaes 队列中并唤醒任何正在等待的线程
** 接收消息**
和消息发送的情形类似,Mach 消息接收的逻辑也是现在内核中的两个地方,和发送一样,mach_msg_overwrite_trap( ) 从用户态接收请求,而内核态通过mach_msg_receive( ) 接收消息
- 调用current_space( ) 获得当前的IPC空间
- 调用current_map( ) 获得当前的VM控件(vm_map)
- 不对消息的大小进行检查。这种检查没有必要,因为消息在发送时已经验证过了
- 通过调用ipc_mqueue_copyin( ) 获得IPC队列
- 持有当前线程的一个引用。使用当前线程的引用可使它适应使用Mach 的续体(continuation)模型,续体模型可以避免维护完整线程栈的必要性
- 调用ipc_mqueue_receive( )从队列中取出消息
- 最后,调用mach_msg_receive_results( ) 函数。这个函数也可以从续体中调用
同步原语
消息传递机制只是Mach IPC架构中的一个组件。另一个组件是同步机制(synchronization),同步机制用于判定两个或多个并发的操作如何访问共享资源。Mach 的同步原语如下表
对象 | 所有者 | 空可见性 | 等待 |
---|---|---|---|
互斥体(lck_mtx_t) | 1个 | 内核态 | 阻塞 |
信号量(semaphore_t) | 多个 | 用户态 | 阻塞 |
自旋锁(hw_lock_t等) | 1个 | 内核态 | 忙等 |
锁集(lock_set_t) | 一个 | 用户态 | 阻塞 |
Mach 的锁也是由两个层次组合而成的:
- 硬件相关层:依赖于硬件的特殊性质,并且通过特定的汇编指令实现原子性和互斥性
- ** 硬件无关层**:通过统一的API包装硬件特定的调用。这些API使得Mach 之上的层(或用户 API)完全不用关心实现的细节,这通常是通过一组简单的宏实现的
锁组对象
大部分Mach 同步对象都不是自己独立存在的,而是属于一个 lck_grp_t 对象。lck_grp_t 就是一个链表中的一个元素,带有一个给定的名字,以及最多3种锁的类型:自旋锁、互斥锁和读写锁。锁组还带有统计信息(lck_grp_stat_t 数据结构),用于调试和同步相关的问题。在Mach 和 BSD 中几乎每一个子系统在初始化时都会创建一个自己使用的锁组。
互斥体对象
互斥体是最常用的锁对象。互斥体定义为lck_mtx_t,互斥体必须属于一个锁组。
读写锁对象
互斥体有一个最大的缺点,就是一次只能有一个线程持有锁。在很多情况下,多个线程可能对资源请求只读的访问,这些情况下,使用互斥锁会阻止并发访问。读写锁(read-write lock)就是问题的解决方案。读写锁是个“更智能”的互斥体,能够区分读访问和写访问。多个读者可以同时持有锁,而一次只能有一个写者可以获得锁。
自旋锁对象
互斥体和信号量都是阻塞等待的对象。阻塞等待的意思是说:如果锁对象被其他线程持有,那么请求访问的线程就被加入到等待队列中,因而被阻塞。阻塞一个线程就意味着放弃线程的时间片,把处理器让给调度器认为下一个要执行的线程。当锁可用时,调度器会得到通知,然后根据自己的判断将线程从等待队列中取出并重新调度。然而这个方式可能会严重地影响性能,由于在很多情况下,锁对象只需要持有短短几个周期的时间,因而造成了两次或更多次的上下文切换带来的开销则要大好几个数量级。这种情况下,如果线程不是放弃处理器,而是重复地尝试访问锁对象可能是更明智的选择,这种方式称之为“忙等(busy-wait)”,如果当前锁的持有者确实在几个周期后就放弃锁了,那么这样就可以节省至少两次上下文切换。当然这个锁要慎用,否则很可能进入一个非常可怕的死锁场景,导致整个系统陷入停滞状态。
信号量对象
Mach 提供了信号量(semaphore),信号量是泛化的互斥体。互斥体的值只能是0和1,而信号量的值这样的一种互斥体。取值可以达到某个正数,即允许并发持有信号量的持有者的个数,换句话说,互斥体可以看成是二值信号量的特殊情况。信号量可以在用户态使用,而互斥体只能在内核态使用。信号量本身是一个不可锁的对象。信号量对象是一个很小的结构体,包含指向所有者和端口的引用。此外,还保护杆一个wait_queue_t,这是一个保存正在等待这个信号量的线程的链表。wait_queue_t会通过硬件所的方式锁定。信号量还有一个有意思的属性:信号量可以转换为端口,也可以由端口转换而来。
锁集对象
任务可以在用户态使用锁集。锁集就是锁(实际上就是互斥体)的数组。通过给定的锁ID 可以访问锁。锁也可以传递给其他线程。交出一个锁会阻塞交出锁的线程,并唤醒接受锁的线程。锁集实际上是对内核互斥体lck_mtx_t的封装,如下图所示:
锁集的有趣之处在于允许锁的传递。锁的传递指是将锁从一个任务传递给另一个任务的过程。Mach 在调度中也使用了传递的概念,允许一个线程放弃处理器但是指定哪一个线程接替允许。
机器原语
Mach 通过一些所谓的“机器原语”对运行的机器进行抽象,机器原语处理的对象包括主机、时钟、处理器以及处理器集。
主机对象
Mach 最基础的对象是“主机(host)”,也就是表示机器本身的对象。主机对象是一个简单的数据结构。主机只不过是一组“特殊端口”的集合(用于向主机发送各种消息),以及一组异常处理程序的集合。主机定于了一个锁组用于保护异常处理的并发访问。
主机的数据结构主要有三个基本功能:
- 提供机器信息:Mach 提供了一组异常丰富的API调用用于查询机器信息,所有这些调用都要求获得主机端口才能工作。
- 提供子系统的访问:通过主机抽象,应用程序可以请求访问子系统使用的任何“特殊”端口。此外,还可以获得所有其他机器抽象(例如:processor 和 processor_set)的访问权。
- 提供默认的异常处理:异常从线程基本提升到进程(任务)基本,如果没有被处理的话。则进一步提升到主机级别做通用的处理。
时钟对象
Mach 内核提供了一个简单的“时钟(clock)”对象抽象。这个对象用于计时和闹钟。时钟是一个带有两个端口的对象:一个用于“服务类”的函数(例如报时或闹铃),另一个用于“控制类”的函数,例如设置一天中的时间。
处理器对象
处理器(processor)对象表示机器上的一个逻辑CPU 或 CPU 核心。如今多核架构已是默认架构,多核架构中的每一个核心都可以看出一个CPU,处理器被分配给处理器集,处理器集是一个或多个处理器的逻辑分组。处理器是CPU的简单抽象,被Mach 用于一些基本的操作,例如启动和关闭一个CPU,以及向CPU分发要执行的线程。
处理器集对象
一个或多个processor_t 对象可以分组为处理器集(processor set),或称为pset(这是processor对象中的processor_set 成员),处理器集是将处理器绑定在一起的逻辑分组,Mach 可以以处理器集作为相关处理器的容器,从而能够高效地扩展到SMP架构。
pset 中的处理器通过两个队列进行维护:一个是active_queue,保存当前正在执行的处理器,另一个是idle_queue,用于保存当前空闲的处理器(即正在执行idle_thread的处理器)。处理器集还有一个全局的run_queue(pset_runq),这个队列保存了在这个集合中的处理器上执行的线程。和其他所有对象一样,处理器集也暴露一些端口:pset_self(用于对处理器集进行操作) 和 pset_name_self(用于获得处理器集的消息)