iOS 并发编程之Dispatch Sources

257 阅读20分钟

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

iOS并发编程系列

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

关于Dispatch Sources

协调底层系统事件处理的对象,例如文件系统事件、计时器和 UNIX 信号。

Grand Central Dispatch 支持以下类型的dispatch sources:

  • 计时器调度源生成定期通知。
  • 当 UNIX 信号到达时,信号调度源会通知。
  • 描述符源会通知您各种基于文件和套接字的操作,例如:
    • 当数据可供读取时
    • 何时可以写入数据
    • 当文件系统中的文件被删除、移动或重命名时
    • 当文件元信息改变时
  • 进程调度源会通知您流程相关的事件,例如:
    • 当一个进程退出时
    • 当进程发出 fork 或 exec 类型的调用时
    • 当一个信号被传递给进程时
  • Mach 端口调度源会通知您有关 Mach 的事件。
  • 自定义调度源是自己定义和触发的。 调度源取代了通常用于处理系统相关事件的异步回调函数。配置调度源时,需要指定要监视的事件以及用于处理这些事件的调度队列和代码。可以使用block对象或函数指定代码。当感兴趣的事件到达时,调度源将您的块或函数提交到指定的调度队列以执行。

与手动提交到队列的任务不同,调度源为应用程序提供了连续的事件源。一个调度源一直连接到它的调度队列,直到你明确地取消它。连接时,只要发生相应的事件,它就会将其关联的任务代码提交到调度队列。某些事件(例如计时器事件)定期发生,但大多数仅在特定条件出现时偶尔发生。出于这个原因,调度源保留其关联的调度队列,以防止它在事件可能仍处于未决状态时过早释放。

为了防止事件在调度队列中积压,调度源实现了一个事件合并方案。如果新事件在前一个事件的事件处理程序出列并执行之前到达,则调度源将来自新事件数据的数据与来自旧事件的数据合并。根据事件的类型,合并可能会替换旧事件或更新它所持有的信息。例如,基于信号的调度源仅提供有关最新信号的信息,但还报告自上次调用事件处理程序以来已传递的总信号数。

Creating Dispatch Sources

  1. 使用 dispatch_source_create 函数创建调度源。
  2. 配置调度源:
    • 将事件处理程序分配给调度源.(编写和安装事件处理程序)
    • 对于定时器源,使用 dispatch_source_set_timer 函数设置定时器信息
  3. Optionally assign a cancellation handler to the dispatch source;(安装取消处理程序)
  4. 调用dispatch_resume函数开始处理事件; 请参阅暂停和恢复调度源。 因为调度源需要一些额外的配置才能使用,所以 dispatch_source_create 函数返回处于挂起状态的调度源。 挂起时,调度源接收事件但不处理它们。 这使您有时间安装事件处理程序并执行处理实际事件所需的任何其他配置。

以下部分向您展示如何配置调度源的各个方面。

编写和安装事件处理程序

要处理调度源生成的事件,必须定义一个事件处理程序来处理这些事件。事件处理程序是使用 dispatch_source_set_event_handlerdispatch_source_set_event_handler_f 函数安装在调度源上的函数或block对象。当事件到达时,调度源将事件处理程序提交到指定的调度队列进行处理。

事件处理程序的主体负责处理任何到达的事件。如果事件处理程序已排队等待新事件到达时处理事件,则调度源将合并这两个事件。事件处理程序通常只看到最近事件的信息,但根据调度源的类型,它也可能能够获取有关发生和合并的其他事件的信息。如果在事件处理程序开始执行之后有一个或多个新事件到达,则调度源会保留这些事件,直到当前事件处理程序完成执行。此时,它将事件处理程序与新事件一起再次提交到队列。

基于函数的事件处理程序采用单个上下文指针,包含调度源对象,并且不返回任何值。基于block的事件处理程序不带参数,也没有返回值。

// Block-based event handler
void (^dispatch_block_t)(void)

// Function-based event handler
void (*dispatch_function_t)(void *)

dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ,
                                 0, 0, myQueue);

dispatch_source_set_event_handler(source, ^{

   // Get some data from the source variable, which is captured

   // from the parent context.

   size_t estimated = dispatch_source_get_data(source);

   // Continue reading the descriptor...

});

dispatch_resume(source);

调用事件处理程序代码来获取有关事件的信息的函数,如下,

FunctionDescription
dispatch_source_get_handle此函数返回调度源管理的底层系统数据类型. 对于描述符调度源,此函数返回一个 int 类型,其中包含与调度源关联的描述符。对于信号调度源,此函数返回一个 int 类型,其中包含最近事件的信号编号。对于进程调度源,此函数返回被监视进程的 pid_t 数据结构。对于 Mach 端口调度源,此函数返回一个 mach_port_t 数据结构。对于其他调度源,此函数返回的值是未定义的。
dispatch_source_get_data此函数返回与事件关联的任何未决数据。对于从文件读取数据的描述符调度源,此函数返回可供读取的字节数。对于将数据写入文件的描述符调度源,如果空间可用于写入,则此函数返回一个正整数。对于监视文件系统活动的描述符调度源,此函数返回一个常量,指示发生的事件类型(dispatch_source_vnode_flags_t 枚举类型)。对于流程调度源,此函数返回一个常量,指示发生的事件类型(dispatch_source_proc_flags_t 枚举类型)。对于 Mach 端口调度源,此函数返回一个常量,指示发生的事件类型(dispatch_source_machport_flags_t 枚举类型)。对于自定义调度源,此函数返回从现有数据创建的新数据值以及传递给 dispatch_source_merge_data 函数的新数据。
dispatch_source_get_mask此函数返回用于创建调度源的事件标志。对于进程调度源,此函数返回调度源接收的事件掩码(dispatch_source_proc_flags_t)。 对于具有发送权限的 Mach 端口调度源,此函数返回所需事件的掩码( dispatch_source_mach_send_flags_t)。对于自定义 OR 调度源,此函数返回用于合并数据值的掩码。

安装取消处理程序

取消处理程序用于在释放调度源之前清理它。对于大多数类型的调度源,取消处理程序是可选的,并且仅当有一些与调度源相关联的自定义行为也需要更新时才需要。但是,对于使用描述符或 Mach 端口的调度源,必须提供取消处理程序来关闭描述符或释放 Mach 端口。如果不这样做,可能会导致代码中出现错误,因为这些结构被代码或系统的其他部分无意中重用。

可以随时安装取消处理程序,但通常在创建调度源时会这样做。可以使用 dispatch_source_set_cancel_handlerdispatch_source_set_cancel_handler_f 函数安装取消处理程序,具体取决于您要在实现中使用块对象还是函数。以下示例显示了一个简单的取消处理程序,它关闭为调度源打开的描述符。 fd 变量是包含描述符的捕获变量。

dispatch_source_set_cancel_handler(mySource, ^{
   close(fd); // Close a file descriptor opened earlier.
});

更改目标队列

尽管在创建调度源时指定了运行事件和取消处理程序的队列,但可以随时使用 dispatch_set_target_queue 函数更改该队列。 可以这样做来更改处理调度源事件的优先级。

更改调度源的队列是一个异步操作,调度源会尽最大努力尽快进行更改。 如果事件处理程序已经排队等待处理,它将在前一个队列上执行。 但是,在进行更改时到达的其他事件可以在任一队列上处理。

将自定义数据与调度源相关联

与 中的许多其他数据类型一样,可以使用 dispatch_set_context 函数将自定义数据与调度源相关联。可以使用上下文指针来存储事件处理程序处理事件所需的任何数据。如果确实在上下文指针中存储了任何自定义数据,您还应该安装一个取消处理程序(如安装取消处理程序中所述)以在不再需要调度源时释放该数据。

如果使用block实现事件处理程序,还可以捕获局部变量并在基于block的代码中使用它们。这样会减轻在调度源的上下文指针中存储数据的需要.因为调度源在应用程序中可能存在很长时间,所以在捕获包含指针的变量时应该小心。如果指针指向的数据可以随时释放,应该复制数据或保留数据以防止这种情况发生。无论哪种情况,都需要提供取消处理程序以稍后释放数据。

Dispatch Source 示例

创建计时器

计时器调度源以基于时间的定期间隔生成事件。可以使用计时器来启动需要定期执行的特定任务。例如,游戏和其他图形密集型应用程序可能会使用计时器来启动屏幕或动画更新。还可以设置一个计时器并使用生成的事件来检查频繁更新的服务器上的新信息。

所有定时器调度源都是间隔定时器——也就是说,一旦创建,它们就会按照您指定的间隔发送常规事件。当创建计时器调度源时,必须指定的值之一是余量值,以使系统对计时器事件所需的准确性有所了解。

该示例计时器每 30 秒触发一次,余量值为 1 秒。因为定时器间隔比较大,所以使用dispatch_walltime函数创建调度源。计时器的第一次触发立即发生,后续事件每 30 秒到达一次。 MyPeriodicTask 和 MyStoreTimer 符号表示将编写的自定义函数,以实现计时器行为并将计时器存储在应用程序数据结构中的某个位置。

dispatch_source_t CreateDispatchTimer(uint64_t interval,
              uint64_t leeway,
              dispatch_queue_t queue,
              dispatch_block_t block)
{

   dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER,
                                                     0, 0, queue);
   if (timer)
   {
   //Leeway 值使系统在管理电源和唤醒内核方面具有一定的灵活性。例如,系统可能会使用 leeway 值来提前或延迟触发时间,并将其与其他系统事件更好地对齐。因此,应该尽可能为您自己的计时器指定一个 leeway 值。
   //注意:即使将 leeway 值指定为 0,计时器也不会在请求的确切纳秒处触发。 该系统会尽力满足您的需求,但不能保证准确的执行时间。
      dispatch_source_set_timer(timer, dispatch_walltime(NULL, 0), interval, leeway);

      dispatch_source_set_event_handler(timer, block);

      dispatch_resume(timer);

   }
   return timer;

}

void MyCreateTimer()
{

   dispatch_source_t aTimer = CreateDispatchTimer(30ull * NSEC_PER_SEC,
                               1ull * NSEC_PER_SEC,
                               dispatch_get_main_queue(),
                               ^{ MyPeriodicTask(); });

   // Store it somewhere for later use.
    if (aTimer){

        MyStoreTimer(aTimer);

    }

}

当计算机进入睡眠状态时,所有计时器调度源都将暂停。当计算机唤醒时,那些定时器调度源也被自动唤醒。根据定时器的配置,这种性质的暂停可能会影响定时器下次触发的时间。如果使用 dispatch_time 函数或 DISPATCH_TIME_NOW 常量设置定时器调度源,则定时器调度源使用默认系统时钟来确定何时触发。但是,当计算机处于睡眠状态时,默认时钟不会提前。相比之下,当使用 dispatch_walltime 函数设置计时器调度源时,计时器调度源将其触发时间跟踪到挂钟时间。后一种选项通常适用于触发间隔相对较大的计时器,因为它可以防止事件时间之间出现太大的偏移。

尽管创建计时器调度源是接收基于时间的事件的主要方式,但也有其他可用选项。 如果要在指定的时间间隔后执行一次块,可以使用 dispatch_afterdispatch_after_f 函数。 此函数的行为与 dispatch_async 函数非常相似,只是它允许指定将block提交到队列的时间值。 根据需要,可以将时间值指定为相对或绝对时间值。

从Descriptor中读取数据

要从文件socket读取数据,需要创建类型为 DISPATCH_SOURCE_TYPE_READ 的调度源。指定的事件处理程序应该能够读取和处理文件描述符的内容。对于文件,这相当于读取文件数据(或该数据的子集)并为您的应用程序创建适当的数据结构。对于socket,这涉及处理新接收到的网络数据。

每当读取数据时,应该始终将描述符配置为使用非阻塞操作。尽管可以使用 dispatch_source_get_data 函数来查看有多少数据可供读取,但该函数返回的数字可能会在进行调用和实际读取数据的时间之间发生变化。如果底层文件被截断或发生网络错误,从阻塞当前线程的描述符中读取可能会在执行过程中停止事件处理程序,并阻止调度队列调度其他任务。对于串行队列,这可能会使队列死锁,甚至对于并发队列,这也会减少可以启动的新任务的数量。

以下代码 显示了一个配置调度源以从文件中读取数据的示例。在此示例中,事件处理程序将指定文件的全部内容读入缓冲区并调用自定义函数(您将在自己的代码中定义)来处理数据。 (一旦读取操作完成,该函数的调用者将使用返回的调度源将其取消。)为了确保调度队列在没有数据可读取时不会不必要地阻塞,本示例使用 fcntl 函数配置文件描述符以执行非阻塞操作。调度源上安装的取消处理程序确保在读取数据后关闭文件描述符。

dispatch_source_t ProcessContentsOfFile(const char* filename)
{
   // Prepare the file for reading.

   int fd = open(filename, O_RDONLY);
   if (fd == -1)
      return NULL;

   fcntl(fd, F_SETFL, O_NONBLOCK);  // Avoid blocking the read operation

   dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

   dispatch_source_t readSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ,

                                   fd, 0, queue);

   if (!readSource)
   {
      close(fd);

      return NULL;

   }
   
   // Install the event handler

   dispatch_source_set_event_handler(readSource, ^{

      size_t estimated = dispatch_source_get_data(readSource) + 1;

      // Read the data into a text buffer.

      char* buffer = (char*)malloc(estimated);

      if (buffer)
      {

         ssize_t actual = read(fd, buffer, (estimated));

         Boolean done = MyProcessFileData(buffer, actual);  // Process the data.

         // Release the buffer when done.

         free(buffer);

         // If there is no more data, cancel the source.

         if (done)

            dispatch_source_cancel(readSource);

      }

    });

   // Install the cancellation handler

   dispatch_source_set_cancel_handler(readSource, ^{close(fd);});

   // Start reading the file.

   dispatch_resume(readSource);

   return readSource;

}

在前面的示例中,自定义 MyProcessFileData 函数确定何时读取了足够的文件数据并且可以取消调度源。 默认情况下,配置为从描述符读取的调度源会在仍有数据要读取时重复调度其事件处理程序。 如果socket连接关闭或到达文件末尾,调度源会自动停止调度事件处理程序。 也可以自己直接取消它。

将数据写入Descriptor

将数据写入文件socket的过程与读取数据的过程非常相似。在为写操作配置描述符之后,创建一个 DISPATCH_SOURCE_TYPE_WRITE 类型的调度源。创建该调度源后,系统会调用事件处理程序,使其有机会开始将数据写入文件或套接字。当完成写入数据后,使用 dispatch_source_cancel 函数取消调度源。

每当写入数据时,应该始终将文件描述符配置为使用非阻塞操作。尽管可以使用 dispatch_source_get_data 函数来查看有多少空间可用于写入,但该函数返回的值只是建议性的,并且可能会在您进行调用和实际写入数据的时间之间发生变化。如果发生错误,将数据写入阻塞文件描述符可能会在执行过程中停止事件处理程序,并阻止调度队列调度其他任务。对于串行队列,这可能会队列死锁,甚至对于并发队列,这也会减少可以启动的新任务的数量。

以下示例 显示了使用调度源将数据写入文件的基本方法。创建新文件后,此函数将生成的文件描述符传递给其事件处理程序。放入文件的数据由 MyGetData 函数提供,可以将其替换为生成文件数据所需的任何代码。将数据写入文件后,事件处理程序取消调度源以防止再次调用它。然后调度源的所有者将负责释放它。

dispatch_source_t WriteDataToFile(const char* filename)
{

    int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC,
                      (S_IRUSR | S_IWUSR | S_ISUID | S_ISGID));

    if (fd == -1)

        return NULL;

    fcntl(fd, F_SETFL); // Block during the write.

    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

    dispatch_source_t writeSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_WRITE,

                            fd, 0, queue);

    if (!writeSource){

        close(fd);

        return NULL;

    }

    dispatch_source_set_event_handler(writeSource, ^{

        size_t bufferSize = MyGetDataSize();

        void* buffer = malloc(bufferSize);

        size_t actual = MyGetData(buffer, bufferSize);

        write(fd, buffer, actual);

        free(buffer);

        // Cancel and release the dispatch source when done.

        dispatch_source_cancel(writeSource);

    });
                                                                         
    dispatch_source_set_cancel_handler(writeSource, ^{close(fd);});

    dispatch_resume(writeSource);

    return (writeSource);

}

监控文件系统对象

如果要监视文件系统对象的更改,可以设置 DISPATCH_SOURCE_TYPE_VNODE 类型的调度源。 当文件被删除、写入或重命名时,您可以使用这种类型的调度源接收通知。 还可以使用它在文件的特定类型的元信息(例如其大小和链接计数)发生更改时收到警报。

以下示例监视文件的名称更改并在更改时执行一些自定义行为。 (将提供实际行为来代替示例中调用的 MyUpdateFileName 函数。)因为描述符是专门为调度源打开的,所以调度源包含一个关闭描述符的取消处理程序。 因为示例创建的文件描述符与底层文件系统对象相关联,所以这个相同的调度源可用于检测任意数量的文件名更改。

dispatch_source_t MonitorNameChangesToFile(const char* filename)
{

   int fd = open(filename, O_EVTONLY);

   if (fd == -1)

      return NULL;

   dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

   dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_VNODE,

                fd, DISPATCH_VNODE_RENAME, queue);

   if (source)
   {

      // Copy the filename for later use.

      int length = strlen(filename);

      char* newString = (char*)malloc(length + 1);

      newString = strcpy(newString, filename);

      dispatch_set_context(source, newString);

      // Install the event handler to process the name change

      dispatch_source_set_event_handler(source, ^{

            const char*  oldFilename = (char*)dispatch_get_context(source);

            MyUpdateFileName(oldFilename, fd);

      });

      // Install a cancellation handler to free the descriptor

      // and the stored string.

      dispatch_source_set_cancel_handler(source, ^{

          char* fileStr = (char*)dispatch_get_context(source);

          free(fileStr);

          close(fd);

      });

      // Start processing events.

      dispatch_resume(source);

   }

   else

      close(fd);

   return source;

}

监测Signals

UNIX 信号允许从其域之外操作应用程序。应用程序可以接收许多不同类型的信号,从不可恢复的错误(例如非法指令)到有关重要信息的通知(例如子进程何时退出)。传统上,应用程序使用 sigaction 函数来安装信号处理函数,该函数在信号到达时立即同步处理。如果只是想收到信号到达的通知而不实际处理该信号,则可以使用信号调度源来异步处理信号。

信号调度源不能替代您使用 sigaction 函数安装的同步信号处理程序。同步信号处理程序实际上可以捕获信号并防止它终止应用程序。信号调度源允许您仅监视信号的到达。此外,不能使用信号调度源来检索所有类型的信号。具体来说,不能使用它们来监视 SIGILL、SIGBUS 和 SIGSEGV 信号。

因为信号调度源在调度队列上异步执行,所以它们不会受到与同步信号处理程序相同的限制。例如,可以从信号调度源的事件处理程序调用的函数没有任何限制。这种增加的灵活性的权衡是,在信号到达的时间和调度源的事件处理程序被调用的时间之间可能会增加一些延迟。

以下代码显示了如何配置信号调度源来处理 SIGHUP 信号。调度源的事件处理程序调用 MyProcessSIGHUP 函数,将在应用程序中将其替换为处理信号的代码。

void InstallSignalHandler()
{
   // Make sure the signal does not terminate the application.

   signal(SIGHUP, SIG_IGN);

   dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

   dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, SIGHUP, 0, queue);

   if (source)
   {

      dispatch_source_set_event_handler(source, ^{

         MyProcessSIGHUP();

      });

      // Start processing signals

      dispatch_resume(source);
   }
}

监控进程

进程调度源允许监视特定进程的行为并做出适当的响应。 父进程可能会使用这种类型的调度源来监视它创建的任何子进程。 例如,父进程可以使用它来监视子进程的死亡。 类似地,子进程可以使用它来监视其父进程并在父进程退出时退出。

以下示例 显示了安装调度源以监视父进程终止的步骤。 当父进程死亡时,调度源设置一些内部状态信息让子进程知道它应该退出。 (自己的应用程序需要实现 MySetAppExitFlag 函数来设置适当的终止标志。)因为调度源自主运行,因此拥有自己,它也取消并释放自己以预期程序关闭。

void MonitorParentProcess()
{

   pid_t parentPID = getppid();

   dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

   dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC,
                                                parentPID, DISPATCH_PROC_EXIT, queue);

   if (source)
   {

      dispatch_source_set_event_handler(source, ^{

         MySetAppExitFlag();

         dispatch_source_cancel(source);

         dispatch_release(source);

      });

      dispatch_resume(source);

   }

}

取消 Dispatch Source

调度源保持活动状态,直到您使用 dispatch_source_cancel 函数显式取消它们。 取消调度源会停止新事件的传递,并且无法撤消。 因此,通常取消一个调度源,然后立即释放它,如下所示:

void RemoveDispatchSource(dispatch_source_t mySource)
{

   dispatch_source_cancel(mySource);

   dispatch_release(mySource);

}

取消调度源是一个异步操作。虽然调用 dispatch_source_cancel 函数后没有新的事件被处理,但是已经被调度源处理的事件会继续被处理。在它完成任何最终事件的处理后,调度源执行它的取消处理程序(如果存在)。

取消处理程序是释放内存或清理代表调度源获取的任何资源的机会。如果调度源使用Descriptormach port,必须提供取消处理程序以在取消发生时关闭描述符或销毁端口。其他类型的调度源不需要取消处理程序,但如果将任何内存或数据与调度源相关联,仍然应该提供一个。例如,如果将数据存储在调度源的上下文指针中,则应该提供一个。

暂停和恢复Dispatch Source

可以使用 dispatch_suspenddispatch_resume 方法临时暂停和恢复调度源事件的传递。 这些方法增加和减少调度对象的挂起计数。 因此,必须在事件传递恢复之前平衡对 dispatch_suspend 的每次调用与对 dispatch_resume 的匹配调用。

当挂起一个调度源时,在该调度源挂起时发生的任何事件都会累积,直到队列恢复。 当队列恢复时,不是交付所有事件,而是在交付之前将事件合并为单个事件。 例如,如果正在监视文件的名称更改,则传递的事件将仅包括姓氏更改。 以这种方式合并事件可防止它们在队列中堆积并在工作恢复时压倒应用程序。