Manager Worker Threads System/zh CN
Lazarus 支持在 Linux 和 Windows 下访问 FPC 多线程环境库。若要开发 Linux 或 64 位 Windows 下的极速原生引擎,Lazarus 是个良好的起点,有助于了解如何最大程度利用现代科学应用所需的多核处理器来实时处理大量数据。
本例旨在提供一种思路,尽量完美地设计一个几乎在每个方面都可重入的多线程系统,并演示如何运用临界区同步对内存对象的访问,从而保护这些对象。
管理线程
下面会尽量多定义一些对象列表、数据结构和临界区,以及能让系统达到一定负载的线程。在示例代码中,将根据工作线程的多少按比例创建管理线程实例。因为只是简单示例,所以比例是静态给出的。但通过合适的加锁机制,此系统可升级为具备动态缩放的能力。
多个工作线程
管理线程维护着多个接受“管理”的线程对象,通常不会过多干涉各个工作线程的运行。高效多线程系统的最佳设计方法,就是让工作线程的代码尽量紧凑,尽量减少临界区的数量。如果需要加锁,请记住所有其他线程可能都会陷入等待状态,直至锁得到释放。
临界区
若要防止多个线程同时往同一位置写入数据,就需要用到临界区。Lazarus 完全支持临界区,本文提及的一些注意事项与 Windows 编程时的类似。
InitCriticalSection(Lock : TRTLCriticalSection) - 此函数名不同于 Windows API 的 InitializeCriticalSection。加锁操作时必须调用此函数。
DoneCriticalSection(Lock : TRTLCriticalSection) - 此函数名也不同于 Windows API 的 DeleteCriticalSection。此函数必须调用,这样操作系统才能释放为线程加锁而分配的内存。
EnterCriticalSection(Lock: TRTLCriticalSection) - 此函数与Windows API 中的同名。调用的位置必须仔细考虑,并且后面一定要跟一个异常处理代码块。
LeaveCriticalSection(Lock: TRTLCriticalSection) - 此函数与Windows API 中的同名。此函数必须在加/解锁代码块的最后才能调用。有一个例外,就是确实需要长时间监测锁的状态。如果某个方法可能会执行很长时间,那么中途解锁是合理的:在预知某项操作会耗费大量时间时,可能应先解锁线程,后续再加锁。这时只需确保全部异常均已处理完毕即可,以便最终能够解锁。
以下是执行加解锁的代码块示例:
EnterCriticalSection(Lock);
try
// 在此执行代码
finally
LeaveCritialSection(Lock);
end;
让线程休眠
在 Windows 系统中,最好不要用 Sleep 方法来让线程进入休眠状态,而应采用 WaitForSingleObject 方法。线程等待事件时会进入一种停滞状态,这时系统几乎不会占用 CPU 周期,直至事件触发或到达指定的超时时间。
FPC 不提供 WaitForSingleObject 方法,因此高效系统的最佳实现方式是使用事件驱动的等待机制。即便中止等待的事件不会发生,最好也是采用事件机制。因此,在后续示例中不用 Sleep(WAIT_MILLISECONDS),而是采用了一种技术,即为每个管理线程创建一个事件句柄。请记住,大多数 Lazarus 应用可能只包含一个管理线程实例,但这些示例单元完全可用于多个管理线程实例的应用。
添加数据
在向本项目添加数据时,请记住每个数据对象都需要在生命周期内分配内存。后续示例中定义了一个数据结构,表示存于网络或本地机器上的某个文件。本项目将输入多个数据对象,先加入队列再进行处理。
对于其他数据对象而言,本项目可以扩展为带有线程池的 SQL 连接器,可提交 SQL 语句查询数据,甚至可在工作线程处理完数据后,通过回调函数将处理后的数据对象存储起来。
推入数据
数据先转换为一种数据结构,再加入准备入队列的数据集中。这样仅当多个线程推入数据时,才会加锁。
这种做法在很多方面都很高效。若从主线程添加数据则永远不会加锁,因为只有主线程才有机会加入数据。若从另一个多线程实例添加数据,则只有往队列添加数据的实例线程才会加锁。因此,这两种添加数据的场景下,全部工作线程都不会休眠。
数据导入队列
数据在到达工作线程之前,必须先导入队列。管理线程负责维护哪些数据已加入、导入队列和最终完成处理。工作线程只管请求需处理的数据即可。数据将以受控方式由管理线程的 Process 方法导入队列。
队列中的数据
待处理的数据会驻留于队列中,直至有某个工作线程请求数据对象。一旦加锁成功,则其他工作线程在获得自己的锁之前无法获得数据。第一个数据对象将移出队列并加入待处理列表,等待下一次获取数据的调用。这种处理方式非常高效,因为一次调用实现了两个目标:将数据对象放入合适的列表,为某个工作线程提供新数据。已加入的数据将以先入先出(FIFO)的方式进行处理,先加入的数据将先被处理。
注: 如果采用 TThreadDataPointers 而非 TList 对象,那么后进先出(FILO)方式可能会更合适。理由是 TList 有 First 方法和 SetLength() 函数可用。那就应以 TThreadDataPointers 最后一个元素为基准,用 SetLength(Length-1) 回收内存空间。这些都是在系统设计时需要考虑的。
数据处理
就工作线程而言,数据处理部分是最关键的代码。变量声明会消耗 CPU。请尽可能将变量声明为线程对象的局部变量。在数据处理方法中声明变量开销很高,即便必须如此也要三思而后行。
- 不要造成等待。
- 采用异常处理机制,否则会干扰线程处理引擎。
- 代码应直奔主题。尽一切可能优化代码。如果代码不够严谨,可能会执行缓慢、开销过高,还会减少系统支持的线程并发数量。
将工作线程对象的变量声明为私有,并在线程销毁事件中予以释放。如果在 Process 方法中,或是为处理单个数据对象而编写的方法中,不停地分配释放内存,开销会很高。
数据处理完毕
本例在调用 GetNextItem 方法时,会将处理完毕的数据推给管理线程。因为这么操作十分高效,此时要将列表加锁,同时将数据移至合适的位置。不过实际需求可能会不一样,因此在确定实现方式前,请仔细考虑该数据的组织方式。
本例未实现回调函数,不会在数据对象处理完毕后通知主应用。对于本例而言,这个功能可有可无,因为只要加入一个文件就几乎可以保证一定会进行处理。
于是就引出了记录日志,议题完全不同但却很有用。在 Windows 环境下使用 TFileStream 是线程安全的,但仅限于 Windows。在 Unix 环境下,可能更倾向于用 TFileStream 加入一个自定义日志文件,而不是将数据发送給系统日志。原因如下,本并发系统正常工作时,每秒可能创建数万条日志记录,而大多数系统管理员不会愿意清理这些日志。
源码文件
完整示例可在 SourceForge Lazarus CCR下载。