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) 回收内存空间。这些都是在系统设计时需要考虑的。
Processing Data
The concept of processing data from a Worker Thread standpoint will be the most critical piece of code you will write. Every declaration is going to cost cycles. If you can, declare variables local to the thread object and let them be, Declaring variables inside the process method is going to be at too high a cost however, processing does need to happen here so just think twice.
- Introduce NO waits here.
- Use Exception Handlers here or it will disrupt the processing engine.
- Be right to the point with your code. Try to optimize everything. The execution of code can be slow and costly and will hamper the number of simultaneous threads your system can allocate if you are sloppy.
Declare variables local to the thread object as private and deallocate them at the Destruction event of the Worker Thread. It would be costly to keep allocating and deallocating memory during the Process method or any methods you add to process a single data object.
Processed Data
This example pushes Processed data during the GetNextItem call to the Manager. This was done because there was an efficiency gain to lock the list at that point and move data where it belongs all at the same time. Your needs may differ however, think about how you want to organize your data before actually committing to the way in which to implement.
This example does not include a callback to notify the main application that a data object has just been completed. It is something that may or may not be required since merely adding a file to this system would pretty much guarantee it was processed.
Which leads us into logging, while entirely another matter, is useful. I have experience with Windows and TFileStream being thread safe but that is *Windows*. Under unix, I would probably stick to adding a custom log file using TFileStream rather than posting data to any system log. The reason is because these simultaneous systems can create tens of thousands of log entries per second if done properly, and most system administrators won't appreciate cleaning out log files on your behalf.
Unit Files
The complete example can be downloaded from Lazarus CCR on SourceForge.