本文参考《Mac OS X and iOS Internals: To the Apple’s Core》 by Jonathan Levin
文章内容主要是阅读这本书的读书笔记,建议读者掌握《操作系统》,了解现代操作系统的技术特点,再阅读本文可以事半功倍。
虽然iOS系统内核使用极简的微内核架构,但内容依然十分庞大,所以会分
系统架构、进程调度、内存管理和文件系统四个部分进行阐述。
1 进程
进程是独立运行、独立分配资源和独立接受调度的基本单位。进程有三个基本状态。
就绪状态
当进已分配到除CPU外的所有必要资源后,只要再获得CPU,便可立即执行,进程这时的状态称为就绪状态。在系统中处于就绪状态的进程往往会有多个,通常将这些进程存入一个队列中,称为就绪队列。执行状态
进程已获得CPU,其程序正在执行。
- 睡眠状态(阻塞状态)
正在执行的进程由于某些事件暂时无法继续执行,便放弃CPU占用转入暂停。阻塞状态的进程也会排入队列中,现代操作系统会根据阻塞原因的不同将处于阻塞状态的进程排入多个队列。导致阻塞的事件有:请求I/O,申请缓冲空间。
在iOS中进程通过Progress ID(进程ID,即PID)来唯一辨识。进程还会将其和父进程的亲属关系保存在父进程ID(Parent Progress ID, PPID)中。父进程可以通过fork(或通过posix_spawn)创建子进程,并且预期子进程会消亡。子进程返回的整数由其父进程收集。
1.1 iOS进程生命周期
上文提到Darwin是双内核系统,由Mach 和 BSD两个部分组成,所以iOS的进程就需要理解两个概念,BSD 进程和Mach 任务。
1.2 BSD 进程 Process
BSD 的进程可以唯一地映射到Mach 任务,但是包含的信息比Mach任务提供的基本调度和统计信息要丰富。BSD 进程包含了文件描述符和信号处理程序的数据。进程还支持复杂的谱系,将进程和其父进程、兄弟进程和子进程连接起来。BSD 在struct proc 中维护了进程的很多特性,struct porc拥有许多字段,因此需要多个锁来保护不同的字段,以及字段参与的列表。进程锁保护整个数据结构,还有一个线程自旋锁、一个文件描述符锁以及其他保护进程组合兄弟进程的锁。
1.3 Mach 任务 task
Mach 并不关心进程,而是使用了比进程更轻量级的概念:任务(task)。经典的UNIX采用了自上而下的方式:最基本的对象是进程,然后进一步划分一个或多个线程。而Mach 采用自底向上的方式,最基本的单元是线程,一个或多个线程包含在一个任务中。
任务是一种容器对象,虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。资源进一步被抽象为端口。因而资源的共享实际上相当于允许对对应端口的访问。
严格地说,Mach 的任务并不是其他操作系统中所谓的进程,因为Mach 作为一个微内核的操作系统,并没有提供“进程”的逻辑,而只是提供了最基本的实现。任务是没有生命的。任务存在的目的就是称为一个或多个线程的容器。任务中的线程都在threads成员中维护,这是一个包含thread_count个线程的队列。此外,大部分对任务的操作实际上就是遍历给定任务中的所有线程,并对这些线程进行对应的线程操作。
1.4 BSD 进程和Mach 任务的关系
这两个概念是一对一的映射关系,每一个BSD 进程都在底层关联了一个Mach 任务对象。实现这种映射的方法是指定一个透明的指针bsd_info,Mach 对bsd_info 完全无知。Mach 将内核也用任务表示。
2 线程 Thread
线程是利用CPU的基本单位,进程是占有资源的基本单位。为了最大化利用进程时间片的方法,引入线程的概念。通过使用多个线程,程序的指向可以分割表面上看上去并发执行的子任务。线程之间切换的开销比较小,只要保存和恢复寄存器即可。多核处理器更是特别和适合线程,因为多个处理器核心共享同样的cache和ARM,为线程间的共享虚拟内存提供了基础。一般一个进程会包括多个线程
线程是Mach中的最小的执行单元。线程表示的是底层的机器寄存器状态以及各种调度统计数据。线程从设计上提供了所需要的大量信息,同时又尽可能地维持最小开销。
在Mach中线程的数据结构非常巨大,因此大部分的线程创建时都是从一个通用的模板复制而来的,这个模板使用默认值填充这个数据结构,这个模板名为thread_template,内核引导过程中被调用的thread_bootstrap( )负责填充这个模板。thread_create_internal( )函数分配新的线程数据结构,然后将换这个模板的内容负责到新的线程数据结构中。
Mach API thread_create( ) 就是通过thread_create_internal( )实现的。
3 调度 Scheduling
多进程程序系统中,作业被提交后,必须经过处理机调度,才能被处理机执行。进程调度主要有两种方式,非抢占式和抢占式。现在面向用户的操作系统基本上都采用抢占式调度方式,包括iOS。主要的抢占原则有:
- 优先权原则
- 短作业优先原则
- 时间片原则
由于Mach具有处理器集的抽象,所以 Mach 比Linux 和 Windows 更擅长管理多核处理器:Mach 可以将同一个CPU 的多个核心放在同一个pset管理,并且通过不同的pset管理不同的CPU。
3.1 Mach 调度器特性
3.1.1 控制权转交
允许一个线程主动放弃CPU,但不是将CPU放弃给任何其他线程,而是将CPU转交给自己选择的某个特定的线程。由于Mach 是一个基于消息传递的内核,线程之间通过消息传递通讯,所以这项特性在Mach 中特别有用。通过这个特性,消息的处理延迟可以达到最小,而不需要投机地等待消息处理线程(发送者或接收者)下一次得到调度。这个特性是Mach特有的。
3.1.2 使用续体
可以使线程不用管理自己的栈,线程可以丢弃自己的栈,系统恢复线程执行时不需要恢复线程的栈。续体是缓解上下文切换开销的简单有效的机制。这个特性是Mach特有的。
3.1.3 异步软件陷阱 Asynchronous Software Trap,AST
是软件对底层硬件陷阱机制的补充完善,通过使用AST,内核可以响应需要得到关注的out-off-band事件,例如调度事件。
AST是人工引发的非硬件触发的陷阱。AST是内核操作的关键部分,而且是调度时间的底层机制,也是BSD信号的实现基础。AST实现为线程控制块中一个包含各种标志位的字段,这些标志位可以通过thread_ast_set( )分别设置。这个特性是Mach特有的。
3.1.4 上下文切换(content switch)
上下文切换是暂停某个线程的执行,并且将其寄存器状态记录在某个预定义的内存位置中。寄存器状态是和及其相关的。当一个线程被抢占时,CPU 寄存器中会价值另一个线程保存的线程状态,从而恢复到那个线程的执行。
一个线程在CPU上可以执行任意长的时间。执行(execute)指的是这样的一个事实:CPU 寄存器中填满了线程的状态,因此CPU(通过EIP/RIP指令指针或PC程序计数器)执行该线程函数的代码。这个执行会一直持续,直到发生下面某种情况:
- 线程终止
- 线程自愿放弃
- 外部中断打断了线程的执行,外部中断要求CPU 保存线程状态并且立即执行中断处理代码
3.1.5 优先级
每一个Mach线程都包含优先级信息,优先级直接影响线程被调度的频率。Mach 有128个优先级。
内核线程的最低优先级为80,比用户态线程的优先级要高。可以保证内核以及用户维护管理的线程能够抢占用户态的线程。
3.1.6 优先级偏移
给线程分配优先级只是一个开头,这些优先级在运行时常常需要调整。Mach 会针对每一个线程的CPU 利用率和整体系统负载动态调整每一个线程的优先级。
3.1.7 运行队列
线程是通过运行队列管理的。 运行队列是一个多层列表,即一个列表的数组,针对128个优先级中的每一个优先级都要一个队列。Mach 实际采用的方法是检查位图,这样就可以同时检查32个队列,这样时间复杂度为O(4)。
3.1.8 等待队列
当进程或者线程阻塞,就没有必要考虑调度这个线程,因为只有当线程等待的对象或I/O 操作完成或时间发生时才能继续执行。所以可以将线程放在等待队列中。当等待的条件满足之后,一个或多个等待的线程可以被解除阻塞并且再次分发执行。
3.1.9 CPU 亲缘性
在使用多核、SMP 或 超线程的现代架构中,还可以设置某个线程和一个或多个指定CPU 的亲缘性(affinity)。这种亲缘性对于线程和系统来说都是有好处的,因为当线程回到同一个CPU上执行时,线程的数据可能还留在CPU的缓存中,从而提升性能。
在Mach中,线程对CPU 的亲缘性的意思就是绑定。thread_bind( )的目的就是绑定线程,这个函数仅仅是更新thread_t的bound_processor字段。如果这个字段被设置为PROCESSOR_NULL之外的任何值,那么未来的调度策略就会将这个线程分发到对应处理器的运行队列。
3.2 Mach调度抢占模式
显式抢占
即线程放弃CPU的控制权或进入阻塞的操作,显式抢占是事先可以预知的,所以显式抢占是同步的隐式抢占
这种抢占是由中断引起的,由于中断不可预测的本身,所有隐式抢占是异步的
3.3 Mach 中断模式
抢占式操作系统必须具备中断能力。中断一般由 CPU 中的一个特殊组件产生的。这个组件称为可编程中断控制器(Programmable Interrupt Controller,PIC),在更加高级的CPU中,称之为高级可编程中断控制器(Advanced PIC,APIC)。PIC 接收来自系统总线上的设备的消息,然后将消息分拣到某一条中断请求(Interrupt Request,IRQ)线上去。当产生中断的时候,PIC将相应的中断标记为活跃。在这个中断被一个函数(称为中断处理程序或中断服务程序)处理或服务完成之前,这条中断线一直保持活跃状态。处理这个中断的函数要负责重置这条线的状态。
Mach的调度是由中断驱动的,对于抢占式多任务系统来说,必须有某种机制允许调度器能够首先得到CPU的控制权,从而抢占当前正在执行的线程,然后才能执行调度算法,并且通过调度算法决定当前的线程可以继续恢复执行还是要抢夺其 CPU 给更重要的线程使用。为了能够从当前运行的线程抢夺CPU,现在的操作系统利用了现有的硬件中断机制。由于中断的特点是强迫CPU在发生中断时“放下手中所有的任务”,并longjmp 跳转到中断处理程序(也称为中断服务例程(interrupt service routinr,ISR))执行,因此可以通过中断机制在发生中断时运行调度器。
3.4 Mach 调度算法
调度算法是模块化的,系统引导时可以动态设置调度器(使用sched引导参数)。不过实际中只用了一个调度器
Mach 的线程调度算法高度可扩展,而且运行更换用于线程调度的算法。通常情况下,只启用了一个调度器。但是Mach的架构运行定义额外的调度器,并且在编译时根据CONFIG_SCHED_的定义设置调度器。每一个调度器对象都维护一个sched_dispatch_table 数据结构,其中以函数指针的方式保存了各种操作。一个全局表sched_current_dispatch保存了当前活动的调度算法,并且允许运行时切换调度器。所有的调度器都必须实现相同的字段,通用的调度逻辑可以通过SCHED宏访问这些字段。
4 IPC
Inter-Process Communication 是指进程间的信息交换,所交换的信息量,少者是一个状态或数值,多者则是成千上万字节。IPC的方式主要有共享存储器、消息系统、管道通信。
iOS的IPC核心机制是消息,在Mach中一切以消息为媒介。
4.1 Mach 内核设计原则
Mach 采用的是极简主义:具有一个简单最小的核心,支持面向对象的模型,使得独立的具有良好定义的组件,实际上就是子系统。可以通过消息的方式互相通讯。在Mach 中,所有的东西都是通过自己的对象实现的。进程(在Mach 中称为任务)、线程、虚拟内存都是对象,所有对象都有自己的属性。
Mach 对象就是C语言结构体加上函数指针。Mach 的独特之处在于选择了通过消息传递的方式实现对象和对象之间的通信。XNU 的“官方”API 是BSD 的POSIX API,苹果保持Mach绝对的极简。由于外层具有非常丰富的Cocoa API,所以很多开发者都根本意识不到Mach的存在。不过,Mach调用仍然是整个架构中最基础的部分。
4.2 Mach 消息
Mac 中最基本的概念就是消息,消息在端口(port)之间传递。消息是Mach IPC 的核心构建块。Mach 消息的设计考虑了参数串行话、对齐、填充和字节顺序的问题。
Mach 消息的发送和接收都是通过同一个API函数 mach_msg( )进行的。
这个函数在用户态和内核中都有实现的。
Mach 消息原本是为真正的微内核架构而设计的。也就是说,mach_msg( )函数必须在发送者和接收者之间复制消息所在的内存。尽管这种实现忠实于微内核的范式,但是事实证明频繁内存复制操作带来的性能损耗是不能忍受的,因此,XNU 将所有的内核组件都共享一个地址空间,因此消息传递只需要传递消息的指针就可以了,从而省去了昂贵的内存复制操作。
为了实现消息的发送和接收,mach_msg( ) 函数调用了一个Mach 陷阱(trap)。Mach 陷阱就是和系统调用的概念,在用户态调用mach_msg_trap( ) 会引发陷阱机制,切换到内核态,在内核态中,内核实现的mach_msg( ) 会完成实际的工作。
消息在口之间传递,端口是32位整型标识符。所有的mach 原生对象都是通过对于的端口访问的。也就是说,查找一个对象的句柄时,实际上请求的是这个对象端口的句柄。
4.3 Mach消息传递机制
用户态的Mach消息传递使用mach_msg( )函数。
这个函数通过内核的Mach 陷阱机制调用内核函数mach_msg_trap( ) 。然后mach_msg_trap( )调用 mach_msg_overwrite_trap( ),mach_msg_overwrite_trap( )
通过测试MACH_SEND_MSG和MACH_REV_MSG标志位来判断发送操作还是接收操作
4.4 消息发送实现
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 队列中并唤醒任何正在等待的线程
4.5 消息接收实现
和消息发送的情形类似,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( ) 函数。这个函数也可以从续体中调用
5 同步机制 synchronization
为使系统的多线程和进程能有条不紊地运行,在系统中必须提供用于实现线程间或者进程间同步的机制。
在Mach系统中主要有以下几种同步机制
对象 | 所有者 | 空可见性 | 等待 |
---|---|---|---|
互斥锁(lck_mtx_t) | 1个 | 内核态 | 阻塞 |
信号量(semaphore_t) | 多个 | 用户态 | 阻塞 |
自旋锁(hw_lock_t等) | 1个 | 内核态 | 忙等 |
锁集(lock_set_t) | 1个 | 用户态 | 阻塞 |
大部分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的封装
6 XPC
XPC是iOS 5引入的轻量级进程间通讯机制,XPC 和 GCD 结合的非常紧密,XPC 允许开发者将应用程序分解为独立的组件。这样可以同时增强应用程序的稳定性和安全性,因为不稳定的功能可以包含在一个XPC服务中,而XPC服务可以在外部进行管理。
XPC 服务程序和客户程序都链接了libxpc.dylib,libxpc.dylib 提供了各种各样的C语言层次的XPC机制,NSXPCConnection。XPC 还依赖于两个私有框架:XPCService 和 XPCObjects。前者负责处理XPC服务运行时相关的事务,后者为XPC 对象提供编码和解码服务。iOS 还有一个包含私有框架 XPCKit。
6.1 XPC 对象类型
XPC对各种数据进行包装盒序列化,这种方式类似于CoreFoundation框架。任何类型的XPC对象都可以处理为不透明的类型 xpc_object_t, 并且通过 xpc_object(3)文档中描述的函数进行操作。这些函数包括xpc_retain/release、xpc_get_type、xpc_hash(提供对象的散列值,可以用于数组索引)、xpc_equal(用于比较对象)和xpc_copy。
6.2 XPC 消息
对象可以通过消息来发送和接收。默认情况下消息是异步发送的,并且通过分发队列(GCD)处理。通过使用屏障(barrier),开发人员可以指定一个代码块在某个连接上的所有消息都发生完成之后执行。发出器的消息可以有应答,应答也是异步的,通过_reply_sync 函数可以阻塞直到收到应答消息。XPC消息是通过Mach 消息机制实现的,并且使用了Mach Interface Genetator(MIG)实施。后者提供了xpc_domain子系统。xpc_domain 子系统包含用于登记、加载或添加服务以及获得服务名称的消息。
6.3 xpc_connection_send_message 流程:
6.4 XPC 服务
XPC 服务可以通过Objective-C 或C或C++创建。不管通过哪种语言创建,都需要调用 libxpc.dylib 库的 xpc_main 函数开始服务。C/C++ 的 服务 mian 函数只不过是一个简单的包装函数,它调用xpc_main,并传入时间处理函数(xpc_connection_handler_t)。Objective-C服务也调用xpc_main,不过是通过NSXPCConnection_t的resume方法间接调用的。
事件处理函数接受单独一个参数:xpc_connection_t(Objective-C 将这个对象封装为Foundation.framework 框架中的 NSXPCConnection)。XPC 连接是一个不透明的对象,需要通过xpc_connection_*函数进行操作。
XPC服务程序的一般架构包括:调用dispatch_queue_create 创建一个队列用于接收来接收自客户程序的消息,然后通过xpc_connectiona_set_target_queue 将这个队列分配给连接。服务程序还有设置连接的时间处理程序:调用 xpc_connection_set_event_handle 并提供一个表示处理程序的代码执行(代码本身也可以包装其他函数)。每当服务程序收到一条消息的时候都会调用这个处理程序。服务程序可以创建一个应答(通过调用 xpc_dictionary_create_reply)并将应答消息发送出去。
6.5 XPC实现代码
- 发送代码
- (void)sendXPC
{
const char *connectionName = "com.moft.XPCService.XPCService";
connection = xpc_connection_create(connectionName, NULL);
xpc_connection_set_event_handler(connection, ^(xpc_object_t object){
double result = xpc_dictionary_get_double(object, "result");
NSLog(@"%f",result);
});
xpc_connection_resume(connection);
xpc_object_t dictionary = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_double(dictionary, "value1", 1.0);
xpc_dictionary_set_double(dictionary, "value2", 2.0);
xpc_connection_send_message(connection, dictionary);
}
- 接收代码
static void XPCService_event_handler(xpc_connection_t peer)
{
xpc_connection_set_event_handler(peer, ^(xpc_object_t event) {
xpc_type_t type = xpc_get_type(event);
//处理业务
double value1 = xpc_dictionary_get_double(event, "value1");
double value2 = xpc_dictionary_get_double(event, "value2");
xpc_object_t dictionary = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_double(dictionary, "result", value1+value2);
xpc_connection_send_message(peer, dictionary);
});
xpc_connection_resume(peer);
}
void receiveXPC
{
xpc_main(XPCService_event_handler);
}
总结
Mach是核心的核心,在iOS系统中进程和线程的调度都是由Mach负责。port是Mach 中最重要的概念,是几乎所有Mach 对象实现的基础。消息在端口间传递,并且允许消息进行各种操作。
Mach 内核的IPC就是在消息的基础上实现的。Mach中没有进程,任务就是Mach的进程,在Mach中所有的组件都是对象,线程、虚拟内存都是对象,所有对象都有自己的属性。
Mach 对象就是C语言结构体加上函数指针。