iOS 多线程编程之概述

378 阅读17分钟

这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战

iOS多线程编程系列

  1. iOS 多线程编程之概述
  2. iOS 多线程编程之RunLoop
  3. iOS 多线程编程之同步

简介

该文偏多线程理论相关,并没有详细去说明相关多线程技术方案的API调用.

线程是可以在单个应用程序内同时执行多个代码路径的技术之一。 尽管NSOperationGrand Central Dispatch (GCD) 等新技术为实现并发性提供了更现代、更高效的基础架构,但 OS X 和 iOS 也提供了用于创建和管理线程的接口。
本文主要描述的就是为支持应用程序中的线程和多线程代码同步而提供的相关技术.

线程概述

线程是什么

线程是一种在应用程序内部实现多条执行路径的相对轻量级的方式。程序内部的多个线程可用于同时或以几乎同时的方式执行不同的任务,并由系统管理,根据系统本身安排它们在可用内核上运行,并根据需要抢先中断它们以允许其他线程运行.
线程是管理代码执行所需的内核级和用户级数据结构的组合。简单来说,内核级用于系统级别是调度,用户级用于代码逻辑的实现.

多线程优势

  • 提高应用程序的感知响应能力.
  • 提高应用程序在多核系统上的实时性能 同样,多线程相较于单线程也会有潜在的问题。比如,多个执行路径可能会为代码增加复杂性,线程之间的必须协调操作,以防止它破坏应用程序的状态信息,多线程访问相同的数据结构可能会以破坏结果数据结构或覆盖另一个线程的更改,即使有适当的保护措施,仍然必须注意编译器优化,这些优化可能引起其他错误.

术语

  • thread(线程)用于指代代码的单独执行路径。
  • process(进程)用于指代正在运行的可执行文件,它可以包含多个线程。
  • task(任务)用于指代需要执行的工作的抽象概念。

线程涉及相关技术

OS X 和 iOS 提供了多种技术来创建线程.此外,还支持管理和同步需要在这些线程上完成的工作.

线程包

线程的底层实现机制是Mach threads,实际开发中通常会使用到POSIX API或者其衍生产品,而不会在Mach级别使用线程。同时,Mach的实现也确实提供了所有线程的基本特性,包括抢占式执行模型调度线程的能力.

实际开发中,应用较多的应该是多线程替代技术,比如NSOperation GCD,均不需要开发者去管理线程周期,只关系需要把任务提交给相关类即可.

当然,如果有些特殊情况下需要去操作线程的话,苹果也提供了相关技术.

NSThread提供了生成新线程和在已经运行的线程上执行代码的方法 详见 Using NSThread 和 Using NSObject to Spawn a Thread.
POSIX threads C语言实现的跨平台的多线程接口,可灵活配置线程,详见Using POSIX Threads.

OS X和iOS平台线程技术基本相同。启动线程后(创建态),线程会以三种主要状态之一运行:运行态就绪态阻塞态。如果线程当前未运行,则它要么被阻塞并等待输入,要么已准备好运行但尚未计划这样做。线程继续在这些状态之间来回移动,直到它最终退出并移动到终止状态(结束态)。 详见后续(线程管理)

RunLoop

Runloop是一种用于管理异步到达线程的事件的基础设施。运行循环通过监视线程的一个或多个事件源来工作。当事件到达时,系统唤醒线程并将事件分派到运行循环,然后将它们分派到指定的处理程序。如果没有事件存在并准备好处理,则运行循环使线程进入睡眠状态。

不需要对创建的任何线程使用运行循环,但这样做可以为用户提供更好的体验。运行循环使创建使用最少资源的长寿命线程成为可能。因为运行循环在无事可做时将其线程置于睡眠状态,所以它消除了轮询的需要,这会浪费 CPU 周期并防止处理器本身进入睡眠状态并节省电力。

以上可理解为主线程的Runloop的能力,如果要配置一个Runloop,所要做的就是启动线程,获取对Runloop对象的引用,安装的事件处理程序,并告诉运行循环运行。 但是,如果计划创建长期存在的辅助线程,则必须自己为这些线程配置Runloop。

同步

线程编程的问题之一是多个线程之间的资源争用。如果多个线程试图同时使用或修改同一个资源,就会出现问题。缓解该问题的一种方法是完全消除共享资源,并确保每个线程都有自己独特的一组资源来操作。但是,当维护完全独立的资源不是一种选择时,您可能必须使用条件原子操作和其他技术来同步对资源的访问。

线程间通信

MechanismDescription
直接通信Cocoa提供了直接在其他线程上执行选择器的能力,一个线程基本上可以在任何其他线程上执行一个方法,因为它们是在目标线程的上下文中执行的,所以以这种方式发送的消息会在该线程上自动序列化.- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait
全局变量,共享内存在两个线程之间传递信息的简单方法是使用全局变量、共享对象或共享内存块。 尽管共享变量既快速又简单,但它们也比直接消息传递更脆弱。 共享变量必须用锁或其他同步机制小心保护,以确保代码的正确性。 不这样做可能会导致竞争条件、数据损坏或崩溃.
Conditions条件是一种同步工具,您可以使用它来控制线程何时执行代码的特定部分。 可以将条件视为看门人,仅在满足规定条件时才让线程运行.
Run loop sources自定义运行循环源是您设置用于在线程上接收特定于应用程序的消息的源。 因为它们是事件驱动的,所以运行循环源会在无事可做时自动让您的线程进入睡眠状态,从而提高线程的效率.
Ports and sockets基于端口的通信是两个线程之间通信的一种更精细的,可靠的技术。 更重要的是,Prot和sockets可用于与外部实体进行通信,例如其他进程和服务. 为了提高效率,端口是使用Runloop源实现的,因此当端口上没有数据等待时,您的线程会休眠.

后续RunLoop模块详细说明port和source相关,传送门

线程使用Tips

避免显式创建线程

手动编写线程创建代码很乏味并且可能容易出错,应该尽可能避免使用它。 OS X 和 iOS 通过其他 API 提供对并发的隐式支持. 与其自己创建线程,不如考虑使用异步 APIGCDNSOperation来完成工作. 这些技术在幕后完成与线程相关的工作,并保证正确完成. 此外,GCD 和NSOperation等技术旨在通过根据当前系统负载调整活动线程的数量来比您自己的代码更有效地管理线程.

保持线程合理繁忙

如果必要手动创建和管理线程,请记住线程会消耗宝贵的系统资源,所以,应该尽最大的努力确保分配给线程的任何任务都是合理的长寿命和高效的. 同时,不应该害怕终止大部分时间处于空闲状态的线程. 线程使用大量内存,因此释放空闲线程不仅有助于减少应用程序的内存占用,还可以释放更多物理内存供其他系统进程使用.

避免共享数据结构

避免线程相关资源冲突的最简单和最简单的方法是为程序中的每个线程提供它自己需要的任何数据的副本。 当最大限度地减少线程之间的通信和资源争用时,并行代码的效果最好。

线程和您的用户界面

用户界面交互相关和UI刷新等操作需要主线程操作,但是例外情况下会从其他线程执行图形操作,可以使用辅助线程来创建和处理图像并执行其他与图像相关的计算。对这些操作使用辅助线程可以大大提高性能。注意,如果不确定某个特定的图形操作,请计划从主线程执行它。

在退出时注意线程行为

如果使用后台线程将数据保存到磁盘或者其他操作时,是不希望应用程序退出的,常驻线程可以解决该类问题,但是需要额外的代码工作,比如,使用 POSIX API 来创建线程。此外,您必须在应用程序的主线程中添加代码,以便在主线程最终退出时加入它们,这种方式是比较底层的,不怎么会使用.另外,可以使用 applicationShouldTerminate: 委托方法将应用程序的终止延迟到以后或完全取消它。

处理异常

每个线程都有自己的调用堆栈,所以每个线程都负责捕获自己的异常。 未能在辅助线程中捕获异常与未能在主线程中捕获异常相同:拥有进程被终止。不能将未捕获的异常抛出给不同的线程进行处理。

如果需要将当前线程中的异常情况通知另一个线程(例如主线程),您应该捕获该异常并简单地向另一个线程发送一条消息,指示发生了什么。 根据您的模型和您要执行的操作,捕获异常的线程可以继续处理(如果可能的话)、等待指令或简单地退出。

库中的线程安全

尽管应用程序开发人员可以控制应用程序是否使用多线程执行,但库开发人员却不能。 在开发库时,您必须假设调用应用程序是多线程的,或者可以随时切换到多线程。 因此,您应该始终对代码的关键部分使用锁。

线程管理

线程开销

线程在内存使用和性能方面对您的程序(和系统)有实际的影响。每个线程都需要在内核内存空间和程序的内存空间中分配内存.创建用户级线程的大致成本也可以量化.

ItemApproximate costNotes
内核数据和结构大约 1 KB该内存用于存储线程数据结构和属性,其中大部分被分配为wired memory,因此无法分页到磁盘.
栈空间512 KB(辅助线程)8 MB(OS X 主线程)1 MB(iOS 主线程)辅助线程允许的最小堆栈大小为 16 KB,堆栈大小必须是 4 KB 的倍数。 此内存的空间在线程创建时在进程空间中留出,但与该内存关联的实际页面在需要时才会创建.
创建时间大约 90 微秒该值反映了从创建线程的初始调用到线程的入口点例程开始执行的时间之间的时间。 这些数字是通过分析在具有 2 GHz Core Duo 处理器和 1 GB RAM 运行 OS X v10.5 的基于 Intel 的 iMac 上创建线程期间生成的平均值和中值确定的.

线程创建

NSThread方式

  • Use the detachNewThreadSelector:toTarget:withObject: class method to spawn the new thread.

  • 初始化 NSThread 对象的简单方法是使用 initWithTarget:selector:object: 方法。 使用一个 NSThread 实例显式调用线程对象的 start 方法.

NSThread* myThread = [[NSThread alloc] initWithTarget:self selector:@selector(myThreadMainMethod:) object:nil];

[myThread start];  // Actually create the thread

注意:使用 initWithTarget:selector:object: 方法的替代方法是继承 NSThread 并覆盖它的 main 方法.使用此方法的覆盖版本来实现线程的主入口点.

pthread

OS X 和 iOS 为使用 POSIX Thread API 创建线程提供了基于 C 的支持。

#include <assert.h>
#include <pthread.h>

//线程执行调用
void* PosixThreadMainRoutine(void* data)
{
    // Do some work here.

    return NULL;
}

//函数创建一个新线程,
void LaunchThread()
{
    // Create the thread using POSIX routines.
    pthread_attr_t  attr;
    pthread_t       posixThreadID;
    int             returnVal;
    returnVal = pthread_attr_init(&attr);
    assert(!returnVal);
    
    //设置属性
    /*
    注意的是如果当线程一旦处于 PTHREAD_CREATE_DETACHED 状态,那么线程的状态就无法再被修改了。线程创建时默认设置为PTHREAD_CREATE_JOINABLE状态

    */
    returnVal = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    assert(!returnVal);
    
    //参数依次:线程ID,属性,执行函数,传值
    int     threadError = pthread_create(&posixThreadID, &attr, &PosixThreadMainRoutine, NULL);

    //销毁
    returnVal = pthread_attr_destroy(&attr);
    assert(!returnVal);
    if (threadError != 0)
    {
         // Report an error.
    }
}
  • PTHREAD_CREATE_DETACHED 分离状态:父线程在创建子线程之后,,父线程不会去等待子线程结束再去运行自己接下来的程序;
  • PTHREAD_CREATE_JOINABLE 状态:父线程会等待子线程运行结束,才继续运行接下来的程序。 重要提示:在应用程序退出时,分离的线程可以立即终止,但可连接的线程不能。 在允许进程退出之前,必须加入每个可连接线程。 因此,在线程正在执行不应中断的关键工作(例如将数据保存到磁盘)的情况下,可连接线程可能更可取。

使用NSObject的方法隐式创建线程

[myObj performSelectorInBackground:@selector(doSomething) withObject:nil];

配置线程属性

配置线程的堆栈大小

对于创建的每个新线程,系统会在您的进程空间中分配特定数量的内存来充当该线程的堆栈。 堆栈管理堆栈帧,也是声明线程的任何局部变量的地方.

配置堆栈大小方式

  • Cocoa : NSThread的方法 setStackSize
  • POSIX: pthread_attr_setstacksize

配置线程本地存储

每个线程都维护一个键值对字典,可以从线程中的任何地方访问。 您可以使用此字典来存储您希望在线程执行期间保留的信息。 例如,可以使用它来存储您希望通过线程运行循环的多次迭代保持的状态信息。

  • Cocoa : NSThread的方法 threadDictionary
  • POSIX: 使用 pthread_setspecificpthread_getspecific 函数来设置和获取线程的键和值。

设置线程的分离状态

大多数高级线程技术默认创建分离线程。在大多数情况下,分离线程是首选,因为它们允许系统在线程完成后立即释放线程的数据结构。分离线程也不需要与您的程序进行显式交互。从线程中检索结果的方法由您自行决定。相比之下,系统不会为可连接线程回收资源,直到另一个线程显式与该线程连接,该进程可能会阻塞执行连接的线程。

可以将可连接线程视为类似于子线程。尽管它们仍然作为独立线程运行,但一个可连接线程必须由另一个线程连接,然后系统才能回收它的资源。可连接线程还提供了一种将数据从退出线程传递到另一个线程的显式方式。就在它退出之前,可连接线程可以将数据指针或其他返回值传递给 pthread_exit 函数。然后另一个线程可以通过调用 pthread_join 函数来声明此数据.

创建可连接的线程唯一的方法就是使用 POSIX Thread。 POSIX 默认将线程创建为可连接的。 要将线程标记为已分离或可连接,请在创建线程之前使用 pthread_attr_setdetachstate 函数修改线程属性。 线程开始后,您可以通过调用 pthread_detach 函数将可连接线程更改为分离线程。

设置线程优先级

创建的任何新线程都有与之关联的默认优先级。 内核的调度算法在确定运行哪些线程时会考虑线程优先级,优先级较高的线程比优先级较低的线程更有可能运行。 较高的优先级并不能保证您的线程有特定的执行时间,只是与较低优先级的线程相比,它更有可能被调度程序选择。

重要提示:通常情况下,将线程的优先级保留为默认值, 增加某些线程的优先级也会增加低优先级线程之间饥饿的可能性。 如果包含必须相互交互的高优先级和低优先级线程,则低优先级线程的饥饿可能会阻塞其他线程并造成性能瓶颈。 具体实现

终止线程

退出线程的推荐方法是让它正常退出其入口点例程。尽管 Cocoa、POSIX 提供了直接杀死线程的例程,但强烈建议不要使用此类例程。杀死一个线程可以防止该线程自行清理。线程分配的内存可能会泄漏,并且线程当前使用的任何其他资源可能无法正确清理,从而在以后产生潜在问题。

如果需要在操作中间终止线程,则应从一开始就设计线程以响应取消或退出消息。对于长时间运行的操作,这可能意味着定期停止工作并检查是否有此类消息到达。如果确实有消息要求线程退出,那么线程将有机会执行任何需要的清理并优雅地退出;否则,它可以简单地返回工作并处理下一个数据块。

响应取消消息的一种方法是使用Runloop输入源来接收此类消息。示例如下,

- (void)threadMainRoutine
{

    BOOL moreWorkToDo = YES;
    BOOL exitNow = NO;
    NSRunLoop* runLoop = [NSRunLoop currentRunLoop];

    // Add the exitNow BOOL to the thread dictionary.

    NSMutableDictionary* threadDict = [[NSThread currentThread] threadDictionary];
    [threadDict setValue:[NSNumber numberWithBool:exitNow] forKey:@"ThreadShouldExitNow"];

    // Install an input source.

    [self myInstallCustomInputSource];

    while (moreWorkToDo && !exitNow)
    {
        // Do one chunk of a larger body of work here.

        // Change the value of the moreWorkToDo Boolean when done.
        // Run the run loop but timeout immediately if the input source isn't waiting to fire.

        [runLoop runUntilDate:[NSDate date]];

        // Check to see if an input source handler changed the exitNow value.

        exitNow = [[threadDict valueForKey:@"ThreadShouldExitNow"] boolValue];
    }

}

该示例在RunLoop上安装了一个自定义输入源,可以从另一个线程;在执行了总工作量的一部分之后,线程会短暂地运行 run loop 以查看是否有消息到达输入源。如果不是,则运行循环立即退出,循环继续下一个工作块。由于处理程序无法直接访问 exitNow 局部变量,因此退出条件通过线程字典中的键值对进行通信。