Thread 基本概念

线程是什么

线程是一个在程序里实现多个执行路径的相对轻量级的方法。系统根据各个程序的需要分配不同的时间片段,让程序之间并行执行。程序内部存在至少一个线程,线程之间又同时执行不同的任务。系统本身直接管理这些线程,在可用的执行单元上运行。

一个线程是用来管理代码执行的内核级和程序级数据结构的组合。内核级数据用来协助完成线程调度和抢占式执行。程序级数据包括函数调用需要的堆栈和线程属性和状态。

单线程程序只有一个执行线程,该线程起止于main。多线程程序开始有一个,需要的时候创建额外的线程。每个新线程有它自己的执行入口。多线程有2个优点:

  • 多线程可以提高程序的感知响应。
  • 多个线程可以提高程序在多核系统上的实时性能。

如果只有一个线程,那么它要做所有的事情:响应事件,更新界面,完成其他计算来实现程序功能等等。单线程的问题是,每个时刻只能做一件事。当程序需要执行一个长时间任务时会发生什么呢?程序会停止响应用户操作来更新界面。如果任务很长,用户会以为程序死了并强制退出。但是,如果把计算任务移到另一个线程去做,那么主线程就很轻松地响应用户操作。

多核电脑能够让执行不同任务的线程在不同的执行单元上同时运行,使得程序在给定的时间内完成更多的任务成为可能。

当然,线程也不总是解决程序性能的万能药。线程带来好处的同时也带来潜在的问题。程序有多个执行路径会增加代码复杂度。每个线程都需要和其他线程协同,来防止信息状态被破坏。因为同一程序的线程共享同一内存地址空间,都可以访问所有的数据。如果两个线程同时操作同一个数据,就有可能破坏该数据。

线程术语

  • 线程(thread)表示一个独立的代码执行路径
  • 进程(process)表示一个正在运行的程序,它可以包含多个线程。
  • 任务(task)表示一个需要运行的任务的抽象

线程备选方案

创建线程给代码带来了不确定性,因为线程相对底层和复杂,如果不完全理解,就很容易遇到同步问题和效率问题。需要考虑清楚是否真的需要多线程。

  • Opertation objects 10.5引入,执行对象是对一个通常在另一个线程执行的任务的封装。这个封装隐藏了执行任务的线程管理细节,更专注于任务本身。通常执行对象和执行队列对象(Operation Queue Object)一起配合使用,执行队列在一个或多个线程上管理这些执行对象的运行。
  • Grand Central Dsipatch(GCD) 10.6引入,用中央调度能专注于任务而不是线程管理。有了GCD,定义要执行的任务,然后加入到负责调度合适线程来执行任务的工作队列里。工作队列相比手动管理,会权衡可用执行单元数量和当前运行状态来更有效地执行任务。
  • Idle-time notifications 对于时间短又不急的任务,空闲时间通知会在程序不忙的时候执行任务。Cocoa用NSNotificationQueue对象支持,通过向NSNotificationQueue默认对象发送包含NSPostWhenIdle选项的通知来请求。NSNotificationQueue会延迟发送通知直到程序空闲。
  • Asynchronous functions 系统接口有很多提供原子同步的异步函数。这些API或者使用系统后台进程或者创建自定义线程来执行任务并返回结果。(实际怎么实现无关要紧,因为它与应用代码隔离。)当设计程序时,查查提供异步功能的函数,考虑用它们代替创建一个新线程同步执行相同的任务。
  • Timers 在主线程用定时器执行不重要到用线程但又需要定期执行的周期性任务。
  • Separate processes 进程虽然比线程重量级,但是当任务与程序无关的时候,创建新的进程来执行或许会有用。如果一个任务需要大量内存或者更高权限时也许用进程实现更好。

线程支持

线程包

多线程的底层实现机制是Mach,但是很少直接使用,而是更多使用方便的POSIX API或者他的衍生方法。Mach没有提供线程所有特征,但是至少包括抢占式的执行模型和调度线程的能力,所以它们相互独立。

  • Cocoa threads Cocoa用NSThreads实现线程。Cocoa也给NSObject添加了方法用新线程或者已经存在的线程来执行代码。
  • POSIX threads POSIX线程为创建线程提供了标准C接口。如果不是写一个Cocoa程序,这是创建线程最好的方法。POSIX接口对配置线程来说相对简单又足够灵活。
  • Multiprocessing Services

应用层上,和其他平台一样,所有线程的行为本质上都是一样的。启动后就进入三个状态的任何一个:运行(running)、就绪(ready)、阻塞(blocked)。如果一个线程当前没有运行,那么它不是处于阻塞,就是等待外部输入,或者已经准备就绪等待分配CPU。线程持续在这三个状态之间切换,直到它运行完或者被终止。

创建新线程时,必须指定入口函数。当函数返回或者中断时,线程永久停止并被系统回收。因为创建线程需要一些时间和内存,是相对比较昂贵的操作,建议在入口函数里多做些任务或者启动一个Runloop进行持久性的任务。

Runloop

Runloop是管理异步到达线程的事件的基本。Runloop为线程监测一个或多个事件源。当事件到达时,系统唤醒线程并把事件分发到Runloop,Runloop再分配给指定的处理方法进行处理。如果没有事件存在或都被处理过,Runloop又会把线程置于休眠状态。

Runloop使用最小资源来创建较长时间运行的线程。因为在没有事件处理时线程会被Runloop置于休眠状态,它没有消耗CPU周期轮询,可以让处理器休眠进而节省电源。

配置Runloop,启动线程,获得Runloop对象,匹配好事件处理方法,告诉Runloop对象开始运行。Cocoa程序的主线程自动配好,子线程必须自己配制。

同步工具

线程编程危险之一就是多线程之间的资源争夺。多线程同一时间使用同一资源就会出现问题。解决方法之一就是,消除共享资源,确保每个线程都只有它能够操作的资源。但完全保持独立是不可行的,所以不得不通过锁,条件,原子操作等其他技术来同步资源访问。

锁提供了同一时刻只有一个线程能执行代码的保护。最普遍的是互斥锁(mutual exclusion),即通常所说的”mutex”。当一个线程试图获取一个已经被其他线程占据的互斥锁时,他会被阻塞直到该锁被释放。

条件确保了程序多线程任务执行的顺序。一个条件是一个看门人,阻塞对应线程直到条件为真。(Operation之间的依赖关系的顺序确定了执行顺训,这和Condition很像。)

原子操作是另一种保护并同步数据访问的方法。当执行标量数据类型的数学运算或者逻辑运算时,原子操作是一种轻量级的方法。原子操作用特殊的硬件指令来确保对一个变量的改变在另一个线程访问它之前就已经完成。

线程间通信

好的设计应最大限度减少线程间通信量,不过不可避免完全没有通信。(线程的任务就是分担程序工作,如果不通信去使用这些工作结果,那程序有什么意义呢?)因为线程共享相同的进程空间,意味着有大量的可选项来进行通信。

  • Direct messaging Cocoa程序支持直接某线程在任何线程上执行方法。因为这些方法在目标线程的上下文中等待被执行,所以这个机制执行的方法在目标线程上被串行执行。
  • Global variables, shared memory, and objects 另一个简单的线程间通信的机制是全局变量,共享对象,或者共享内存块。虽然共享变量又快又简单,但是相比发送消息而言还是更容易引起问题。共享变量必须用同步机制严格保护访问来确保代码正确执行,如果做得不好,可能引起条件竞争,数据损坏,或者崩溃。
  • Conditions 条件控制何时执行一段特定代码。可以把条件看成看门人,只有在条件为真时让线程运行。
  • Runloop sources 设置一个自定义Runloop源用来在线程里接收特定的程序信号。因为它是事件驱动,当没事时让线程休眠节省电源。
  • Ports and sockets 基于端口的通信是线程间通信中又精确又可靠的方法。更重要的是,端口和套接字可以用来和外部对象(其他进程和服务)通信。为了考虑电源,端口被实现为Runloop事件源,所以端口上没有数据等待的时候线程会休眠。
  • Message queues 传统多处理队列定义了一个先进先出的队列,用来管理进出数据。虽然它简单又方便,但是和其他方法比起来不够高效。
  • Cocoa distributed objects

设计要点

避免显式创建线程

尽量避免手动编写线程,这样容易容易出错,应该尽量使用隐式创建线程的API。可以考虑使用异步API,GCD或者操作对象实现多线程机制。这些技术背后做了相关工作,并保证无误。其中GCD和NSOperation被设计用来管理线程,比手动实现根据当前负载调整活动线程的数量更高效。

保持线程合理的忙碌

如果准备手动创建和管理线程,因为线程消耗系统的宝贵资源,应该尽量确保分配的线程运行适当时间做了很多事。同时,不要害怕终止那些消耗了大量空闲时间的线程。线程占用一定量的内存,所以释放空闲线程不但减少程序内存,而且释放更多物理内存给系统其他进程使用。

避免共享数据结构

避免造成线程访问资源冲突最简单有效的方法是,给每个线程一份独立的数据副本。最小化线程间通信和资源竞争时并行代码会最有效执行。

即使代码里面所有共享资源的地方都有保护,代码依然可能语义不安全。比如,一个特定的顺序会修改共享数据结构。将代码改为原子方式,来弥补多线程可能产生的性能损耗。把避免资源争竞争放在首位考虑,通常能得到简单的设计和高效的性能。

线程和用户界面

如果程序有用户界面,建议在主线程里接收界面事件和更新界面。这有助于避免处理界面事件和更新界面之间的同步问题。有些框架,例如Cocoa,通常需要这样的行为,但即使不需要的框架,这么做也简化了程序用户界面的逻辑管理。

也有在子线程里执行图形任务更好的列外。比如,可以用子线程来创建和处理图片或者其他图形相关计算。这么做通常会地提高性能。如果不确定一个任务是否和图像处理相关,那么就在主线程上执行它。

注意线程退出时的行为

进程一直运行直到所有非分离线程都退出为止。默认只有主线程是以非分离方式创建,但也可以用同样方式创建子线程。当要退出程序时,立即中断所有分离线程通常被认为是合适的,因为分离线程所做的工作是可选的。如果程序使用后台线程来保存数据到硬盘或者其他关键工作,那么可以把这些线程创建为非分离的,来保证退出程序时不丢失数据。

以非分离方式(又称为可连接)创建线程需要做一些额外工作。因为大部分上层线程封装技术默认不创建可连接线程。必须使用POSIX API来创建。此外,还必须在程序主线程里添加代码,在非分离线程最终退出的时连接它们。

如果是Cocoa程序,也可以通过使用applicationShouldTerminate:的代理方法来延迟程序的终止直到一段时间后或者取消。当延迟终止时,需要等到所有关键线程都完成任务后,再调用replyToApplicationShouldTerminate:方法。

处理异常

当一个异常抛出时,异常的处理机制根据于当前调用堆栈来执行必要的清理工作。因为每个线程都有它自己的调用堆栈,所以每个线程都负责捕获自己的异常。如果子线程捕获一个抛出的异常失败,那么主线程也同样捕获该异常失败:它所属的进程就会终止。不能抛出一个不能被捕获的异常给另一个线程去处理。

如果需要通知另一个线程(比如主线程)在此线程中的一个异常情况,应该先捕捉该异常,并简单地发送消息到其他线程告知发生了什么事。根据设计和意图,捕获异常的线程可以继续运行(如果可能的话),等待命令,或者干脆退出。

注意:在Cocoa里面,一个NSException对象是一个自包含对象,可以被从一个线程传递到另外一个线程。

在一些情况下,异常处理被自动创建。比如@synchronized包含了一个隐式的异常处理。

干净地终止线程

线程自然退出的最好方式是让它达到入口结束点。虽然有不少函数可以立即终止线程,但是这些函数应作为最后的手段。在线程达到它自然结束点之前终止它,会妨碍它完成清理。如果线程已经分配了内存,打开了文件,或者获取了其他类型资源,可能没法回收这些资源,造成内存泄漏或者其他潜在的问题。

库的线程安全

虽然程序开发者能控制是否多线程,但库的开发者不能控制。当开发库时,必须假设库的调用程序是多线程,或者是和否可以随时切换。因此应该总是在临界区使用锁功能。

对库开发者而言,只当程序是多线程时才创建锁是不明智的。如果代码中某些部分需要被锁定,在库早期开发时就应该创建锁对象来使用,最好是库有显式的初始化并在其中创建锁。虽然也可以使用静态库的初始化函数来创建这些锁,但是仅当没有其他方法时才应该这样做。执行初始化函数会延长加载库的时间,且可能对程序性能造成不利影响。

注意:库里保持锁的占据和释放平衡。应该依赖库里加锁保护数据结构,而不是依赖库外的调用代码,来提供线程安全。

开发Cocoa的类库,在NSWillBecomeMultiThreadedNotification注册一个观察者,程序变成多线程的时候收到通知。不过不要过于依赖它,因为可能在类库被调用之前已经发出了。


延伸阅读


Thread 基本概念
https://hllovesgithub.github.io/2015/09/26/2015-09-26-Thread-基本概念/
作者
Hu Liang
发布于
2015年9月26日
许可协议