iOS 并发编程之Dispatch Queues

616 阅读17分钟

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

iOS并发编程系列

  1. iOS 并发编程之Operation Queues
  2. iOS 并发编程之Dispatch Queues
  3. iOS 并发编程之Dispatch Sources

Grand Central Dispatch (GCD) 调度队列是执行任务的强大工具。 调度队列允许相对于调用者异步同步地执行任意代码块。 可以使用调度队列来执行您过去在单独线程上执行的几乎所有任务。 调度队列的优点是它们比相应的线程代码更易于使用并且在执行这些任务时效率更高.

注:调度队列指的是dispatch queen

关于 Dispatch Queues

调度队列是一种在应用程序中异步并发执行任务的简单方法。 任务是应用程序需要执行的一些工作。 例如,您可以定义一个任务来执行一些计算、创建或修改数据结构、处理从文件中读取的一些数据或任何数量的事情。 可以通过将相应的代码放入函数或块对象中并将其添加到调度队列来定义任务。

调度队列是一种类似对象的结构,用于管理您提交给它的任务。 所有的调度队列都是先进先出的数据结构。 因此,添加到队列中的任务始终按照它们添加的顺序启动。 GCD 会自动为您提供一些调度队列,但可以为特定目的创建其他队列。 以下列出了应用程序可用的调度队列类型以及如何使用它们。

TypeDescription
Serial串行队列(也称为私有调度队列)按添加到队列的顺序一次执行一项任务。 当前正在执行的任务在由调度队列管理的一个线程上运行(可能因任务而异)。 串行队列通常用于同步对特定资源的访问。可以根据需要创建任意数量的串行队列,并且每个队列相对于所有其他队列同时运行。 换句话说,如果您创建四个串行队列,每个队列一次只执行一个任务,但最多仍可以同时执行四个任务,每个队列一个。Creating Serial Dispatch Queues.
Concurrent并发队列(也称为全局调度队列的一种)并发执行一个或多个任务,但任务仍然按照它们添加到队列中的顺序启动。 当前正在执行的任务在由调度队列管理的不同线程上运行。 在任何给定点执行的任务的确切数量是可变的,并且取决于系统条件。在 iOS 5 及更高版本中,您可以通过将 DISPATCH_QUEUE_CONCURRENT 指定为队列类型来自己创建并发调度队列。 此外,还有四个预定义的全局并发队列供您的应用程序使用。 有关如何获取全局并发队列的更多信息Getting the Global Concurrent Dispatch Queues.
Main dispatch queue主调度队列是一个全局可用的串行队列,它在应用程序的主线程上执行任务。 此队列与应用程序的RunLoop(如果存在)一起使用,以将排队任务的执行与附加到运行循环的其他事件源的执行交错。 因为它运行在应用程序的主线程上,所以主队列通常用作应用程序的关键同步点。虽然不需要创建主调度队列,但需要确保应用程序适当地排空它。 有关如何管理此队列的更多信息, see Performing Tasks on the Main Thread.

在向应用程序添加并发性时,调度队列提供了优于线程的几个优点。

  • 最直接的优点是工作队列编程模型的简单性。对于线程,必须为要执行的工作以及线程本身的创建和管理编写代码。调度队列可以专注于真正想要执行的工作,而不必担心线程的创建和管理。相反,系统会为您处理所有线程的创建和管理。
  • 优点是系统能够比任何单个应用程序更有效地管理线程。系统可以根据可用资源和当前系统状况动态扩展线程数量。
  • 此外,系统通常能够比自己创建线程更快地开始运行您的任务。

调度队列的优势在于可预测性。如果有两个任务访问相同的共享资源但在不同的线程上运行,则任何一个线程都可以先修改资源,需要使用来确保两个任务不会同时修改该资源。使用调度队列,可以将两个任务添加到串行调度队列中,以确保在任何给定时间只有一个任务修改了资源。这种类型的基于队列的同步比锁更有效,锁总是需要昂贵的内核开销,而调度队列主要在应用程序的进程空间中工作,只有在绝对必要时才调用内核。更重要的是,线程模型需要创建两个线程,它们同时占用内核和用户空间内存。调度队列不会为它们的线程支付相同的内存损失,并且它们使用的线程保持忙碌而不是阻塞.

Queue相关技术

除了调度队列,Grand Central Dispatch 还提供了几种使用队列来帮助管理代码的技术。

TechnologyDescription
Dispatch groups调度组是一种监控一组 block对象 完成情况的方法。 (可以根据需要同步或异步监视block。)groups为依赖于其他任务完成的代码提供有用的同步机制。 下文会有详解.
Dispatch semaphores调度信号量类似于传统的信号量,但通常更有效。 只有当调用线程因为信号量不可用而需要阻塞时,调度信号量才会向下调用内核。 如果信号量可用,则不进行内核调用
Dispatch sources调度源生成通知以响应特定类型的系统事件。 您可以使用调度源来监视事件,例如进程通知、信号和描述符事件等。 当事件发生时,调度源将你的任务代码异步提交到指定的调度队列进行处理。 有关创建和使用调度源的更多信息,后续会再说明.

Block 简述

由于GCD的任务都是以block的形式执行的,有必要简单说明一下block. block对象是一种基于 C 的语言功能。block可以很容易地定义一个独立的工作单元。尽管它们看起来类似于函数指针,但block实际上由类似于对象的底层数据结构表示,并由编译器创建和管理。编译器将您提供的代码(连同任何相关数据)打包,并将其封装成一种可以存在于中并在您的应用程序中传递的形式。(并不是所有的block都是存在堆上的,但是GCD的是)

block的主要优势之一是它们能够使用自己词法范围之外的变量。在函数或方法中定义块时,该block在某些方面就像传统的代码块一样。例如,一个块可以读取父作用域中定义的变量的值。块访问的变量被复制到堆上的块数据结构中,以便块以后可以访问它们。将块添加到调度队列时,这些值通常必须以只读格式保留。但是,同步执行的块也可以使用带有 __block 关键字的变量将数据返回到父级的调用范围

使用与函数指针所用语法类似的语法来声明与代码内联的block。blcok和函数指针之间的主要区别在于块名称以插入符号 (^) 而不是星号 (*) 开头。像函数指针一样,可以将参数传递给块并从中接收返回值。 以下代码 展示了如何在代码中同步声明和执行块。变量 aBlock 被声明为一个接受单个整数参数并且不返回值的块。然后将与该原型匹配的实际块分配给 aBlock 并声明为内联。最后一行立即执行该块,将指定的整数打印到标准输出。

int x = 123;

int y = 456;

// Block declaration and assignment

void (^aBlock)(int) = ^(int z) {

    printf("%d %d %d\n", x, y, z);
};

// Execute the block

aBlock(789);   // prints: 123 456 789

创建和管理Dispatch Queues

在将任务添加到队列之前,必须决定使用哪种类型的队列以及打算如何使用它。 调度队列可以串行或并行执行任务。 此外,如您对队列有特定用途,则可以相应地配置队列属性。

获取全局并发调度队列

当有多个可以并行运行的任务时,并发调度队列很有用。并发队列仍然是队列,因为它以先进先出的顺序出列任务;但是,并发队列可能会在任何先前的任务完成之前使其他任务出队。并发队列在任何给定时刻执行的实际任务数量是可变的,并且可以随着应用程序中条件的变化而动态变化。许多因素会影响并发队列执行的任务数量,包括可用内核的数量、其他进程正在完成的工作量以及其他串行调度队列中任务的数量和优先级。

系统为每个应用程序提供四个并发调度队列。这些队列对于应用程序是全局的,并且仅通过它们的优先级来区分。因为它们是全局的,所以不必显式创建它们。使用 dispatch_get_global_queue 函数请求队列之一,如以下示例所示:

//除了获取默认并发队列之外,还可以通过将 DISPATCH_QUEUE_PRIORITY_HIGH 和 DISPATCH_QUEUE_PRIORITY_LOW 常量传递给函数来获取具有高和低优先级的队列,
或者通过传递 DISPATCH_QUEUE_PRIORITY_BACKGROUND 常量来获取后台队列。 
高优先级并发队列中的任务在默认和低优先级队列中的任务之前执行。 同样,默认队列中的任务在低优先级队列中的任务之前执行
dispatch_queue_t aQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

注意:dispatch_get_global_queue 函数的第二个参数是为将来扩展保留的。 现在,您应该始终为此参数传递 0。

创建串行调度队列

串行队列一次只执行一个任务,并且总是从队列的头部拉取任务。可以使用串行队列而不是锁来保护共享资源或可变数据结构。与锁不同,串行队列确保任务以可预测的顺序执行。只要将任务异步提交到串行队列,队列就永远不会死锁。

创建自定义串行队列使用 dispatch_queue_create 函数有两个参数:队列名称队列属性。队列属性DISPATCH_QUEUE_CONCURRENT,DISPATCH_QUEUE_SERIAL

dispatch_queue_t queue;

queue = dispatch_queue_create("com.example.MyQueue", NULL);//NULL表示串行

使用 dispatch_get_main_queue 函数获取与应用程序主线程关联的串行调度队列

调度队列的内存管理

调度队列和其他调度对象是引用计数的数据类型。 当您创建一个串行调度队列时,它的初始引用计数为 1。您可以使用 dispatch_retaindispatch_release 函数根据需要增加和减少该引用计数。 当队列的引用计数达到零时,系统异步释放队列。

保留和释放调度对象(例如队列)非常重要,以确保它们在使用时保留在内存中。 与内存管理的 Cocoa 对象一样,一般规则是,如果您打算使用传递给代码的队列,则应在使用之前保留该队列,并在不再需要时释放它。 这种基本模式可确保队列在您使用它时一直保留在内存中.

注意:开发者不需要保留或释放任何全局调度队列,包括并发调度队列或主调度队列。 任何保留或释放队列的尝试都将被忽略。

使用队列存储自定义上下文信息

所有调度对象(包括调度队列)都允许自定义上下文数据与对象相关联。 要在给定对象上设置和获取此数据,请使用 dispatch_set_contextdispatch_get_context 函数。

对于队列,可以使用上下文数据来存储指向 Objective-C 对象或其他数据结构的指针,以帮助识别队列或其对代码的预期用途。 可以使用队列的finalizer函数在释放之前从队列中释放(或解除关联)上下文数据。

void myFinalizerFunction(void *context)
{

    MyDataContext* theData = (MyDataContext*)context;

    // Clean up the contents of the structure

    myCleanUpDataContextFunction(theData);

    // Now release the structure itself.

    free(theData);

}

dispatch_queue_t createMyQueue()
{

    MyDataContext*  data = (MyDataContext*) malloc(sizeof(MyDataContext));

    myInitializeDataContextFunction(data);
                                                      
    // Create the queue and set the context data.

    dispatch_queue_t serialQueue = dispatch_queue_create("com.example.CriticalTaskQueue", NULL);

    dispatch_set_context(serialQueue, data);

    dispatch_set_finalizer_f(serialQueue, &myFinalizerFunction);

    return serialQueue;

}

将任务添加到队列

要执行任务,您必须将其分派到适当的分派队列。 可以同步或异步调度任务,可以单独或分组调度它们。 一旦进入队列,鉴于队列的约束和队列中已有的任务,队列将负责尽快执行任务。

将单个任务添加到队列

将任务添加到队列有两种方法:异步或同步。使用 dispatch_asyncdispatch_async_f 函数的异步执行。使用 dispatch_syncdispatch_sync_f 函数将任务添加到队列同步中。这些函数会阻塞当前的执行线程,直到指定的任务完成执行。

dispatch_queue_t myCustomQueue;

myCustomQueue = dispatch_queue_create("com.example.MyCustomQueue", NULL);

dispatch_async(myCustomQueue, ^{

    printf("Do some work here.\n");

});

printf("The first block may or may not have run.\n");

dispatch_sync(myCustomQueue, ^{

    printf("Do some more work here.\n");

});

printf("Both blocks have completed.\n");

同时执行循环迭代

如果每次迭代期间执行的工作与所有其他迭代期间执行的工作不同,并且每个连续循环完成的顺序并不重要,则可以将循环替换为对 dispatch_applydispatch_apply_f 函数的调用。 这些函数在每次循环迭代时将指定的块或函数提交到队列一次。 因此,当分派到并发队列时,可以同时执行多个循环迭代。

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

    **unsigned** count = 20;

    dispatch_apply(count, queue, ^(size_t i) {

        printf("%zu\n",i);

    });

在主线程上执行任务

Grand Central Dispatch 提供了一个特殊的调度队列,您可以使用它在应用程序的主线程上执行任务。 该队列是自动为所有应用程序提供的,并且由在其主线程上设置运行循环(由 CFRunLoopRef 类型或 NSRunLoop 对象管理)的任何应用程序自动排空。 如果没有创建 Cocoa 应用程序并且不想显式设置运行循环,则必须调用 dispatch_main 函数显式排空主调度队列。 仍然可以将任务添加到队列中,但如果您不调用此函数,这些任务将永远不会执行。

可以通过调用 dispatch_get_main_queue 函数来获取应用程序主线程的调度队列。 添加到此队列的任务在主线程本身上串行执行。 因此,可以将此队列用作应用程序其他部分中正在完成的工作的同步点。

暂停和恢复队列

您可以通过暂停队列来阻止队列临时执行块对象。 您使用 dispatch_suspend 函数挂起一个调度队列并使用 dispatch_resume 函数恢复它。 调用 dispatch_suspend 会增加队列的挂起引用计数,调用 dispatch_resume 会减少引用计数。 当引用计数大于零时,队列保持挂起。 因此,您必须平衡所有挂起调用与匹配的恢复调用,以恢复处理block。

重要提示:挂起和恢复调用是异步的,仅在block执行之间生效。 暂停队列不会导致已经执行的block停止。

使用 Dispatch Semaphores来规范有限资源的使用

如果提交到调度队列的任务访问一些有限的资源,可能希望使用调度信号量来调节同时访问该资源的任务的数量。 调度信号量的工作方式与常规信号量类似,但有一个例外。 当资源可用时,获取调度信号量所需的时间比获取传统系统信号量要少。 这是因为 Grand Central Dispatch 不会针对这种特殊情况调用内核。 它调用内核的唯一时间是当资源不可用并且系统需要停止线程直到信号量发出信号时。

创建信号量时(使用 dispatch_semaphore_create 函数),您可以指定一个正整数来指示可用资源的数量。 在每个任务中,调用 dispatch_semaphore_wait 来等待信号量。 当等待调用返回时,获取资源并完成您的工作。 完成资源后,释放它并通过调用 dispatch_semaphore_signal 函数向信号量发出信号。

// Create the semaphore, specifying the initial pool size

dispatch_semaphore_t fd_sema = dispatch_semaphore_create(getdtablesize() / 2);

// Wait for a free file descriptor

dispatch_semaphore_wait(fd_sema, DISPATCH_TIME_FOREVER);

fd = open("/etc/services", O_RDONLY);

// Release the file descriptor when done

close(fd);

dispatch_semaphore_signal(fd_sema);

创建信号量时,您指定可用资源的数量。 该值成为信号量的初始计数变量。 每次在信号量上等待时,dispatch_semaphore_wait 函数都会将该计数变量减 1。如果结果值为负,该函数会告诉内核阻塞您的线程。 另一方面,dispatch_semaphore_signal 函数将 count 变量增加 1 以指示资源已被释放。 如果有任务被阻塞并等待资源,则其中一个任务随后被解除阻塞并被允许执行其工作。

等待队列任务组

调度组是一种阻塞线程直到一个或多个任务完成执行的方法。您可以在完成所有指定任务之前无法取得进展的地方使用此行为。例如,在分派多个任务来计算一些数据之后,您可能会使用一个组来等待这些任务,然后在它们完成后处理结果。使用调度组的另一种方法是作为线程连接的替代方法。您可以将相应的任务添加到调度组并等待整个组,而不是启动多个子线程然后加入每个子线程。

以下代码 显示了设置组、向其分派任务并等待结果的基本过程。不是使用 dispatch_async 函数将任务分派到队列,而是使用 dispatch_group_async 函数。此函数将任务与组相关联并将其排队等待执行。要等待一组任务完成,然后使用 dispatch_group_wait 函数,传入适当的组

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

dispatch_group_t group = dispatch_group_create();

// Add a task to the group

dispatch_group_async(group, queue, ^{

   // Some asynchronous work

});

// Do some other work while the tasks execute.

// When you cannot make any more forward progress,

// wait on the group to block the current thread.

dispatch_group_wait(group, DISPATCH_TIME_FOREVER);

// Release the group when it is no longer needed.

dispatch_release(group);

调度队列和线程安全

每当应用程序中实现并发时,应该了解以下几点:

  • 调度队列本身是线程安全的。 换句话说,您可以从系统上的任何线程将任务提交到调度队列,而无需先获取锁或同步访问队列。
  • 不要从在传递给函数调用的同一队列上执行的任务调用 dispatch_sync 函数。 这样做会使队列死锁。 如果您需要分派到当前队列,请使用 dispatch_async 函数异步执行此操作。
  • 避免从您提交到调度队列的任务中获取锁。 尽管从任务中使用锁是安全的,但是当您获取锁时,如果该锁不可用,您可能会完全阻塞串行队列。 同样,对于并发队列,等待锁可能会阻止其他任务执行。 如果您需要同步部分代码,请使用串行调度队列而不是锁。