什么时候应该使用临界区?

Posted

技术标签:

【中文标题】什么时候应该使用临界区?【英文标题】:When should I use critical sections? 【发布时间】:2011-03-19 09:01:13 【问题描述】:

这是交易。我的应用程序有很多线程做同样的事情 - 从大文件(> 2gb)中读取特定数据,解析数据并最终写入该文件。

问题是,有时可能会发生一个线程从文件 A 读取 X,而第二个线程写入同一个文件 A 的 X。会发生问题吗?

I/O 代码对每个文件都使用 TFileStream。我将 I/O 代码拆分为本地(静态类),因为我担心会有问题。既然是拆分的,就应该有临界区。

以下每个案例都是未实例化的本地(静态)代码。

案例一:

procedure Foo(obj:TObject);
begin ... end;

案例 2:

procedure Bar(obj:TObject);
var i: integer;
begin
  for i:=0 to X do ...something
end;

案例 3:

function Foo(obj:TObject; j:Integer):TSomeObject
var i:integer;
begin
  for i:=0 to X do
    for j:=0 to Y do
      Result:=something
end;

问题 1:在什么情况下我需要临界区,这样如果 >1 个线程同时调用它就没有问题?

问题 2:如果线程 1 从文件 A 读取 X(entry),而线程 2 将 X(entry) 写入文件 A,会不会有问题?

我试着在脑海中想象它,但这很难——只有一个线程:))

编辑

这样适合吗?

每 2GB 文件一个类

TSpecificFile = class
  cs: TCriticalSection;
  ...
end;

TFileParser = class
  file :TSpecificFile;
  void Parsethis; void ParseThat....
end;

function Read(file: TSpecificFile): TSomeObject;
begin
  file.cs.Enter;
  try
    ...//read
  finally
    file.cs.Leave;
  end;
end;

function Write(file: TSpecificFile): TSomeObject;
begin
  file.cs.Enter;
  try
    //write
  finally
    file.cs.Leave
  end;
end;

现在如果两个线程调用 Read with 会有问题:

案例1:相同的TSpecificFile

案例2:不同的TSpecificFile?

我需要另一个关键部分吗?

【问题讨论】:

我的建议:给自己买一本 Joe Duffy 的书Concurrent Programming on Windows 并正确了解这一点。你无法在零零碎碎中学习并发编程。 关键部分并不是您工具箱中唯一的线程编程工具。我很高兴您也没有决定只使用 TThread.Synchronize。正如大卫所说,在正确设计多线程代码之前,您需要对这个主题有很多了解。这也包括学习何时拆分您的设计。您是否考虑过拥有一个编写器线程和一个处理器线程,并且仅将写入结果排入队列,并且全部由单个编写器线程写入? 只有一个写入器进程可能无法在启用并行 I/O 的系统上扩展,例如一个 RAID... 【参考方案1】:

一般来说,当多个线程可能同时访问共享资源时,您需要一种锁定机制(临界区是一种锁定机制),并且至少有一个线程将写入/修改共享资源。 无论资源是内存中的对象还是磁盘上的文件,都是如此。 而需要加锁的原因是,如果读操作与写操作同时发生,读操作很可能会获得不一致的数据,从而导致不可预知的行为。 Stephen Cheung 已经提到了有关文件处理的平台特定注意事项,我在此不再赘述。

作为旁注,我想强调另一个可能适用于您的情况的并发问题。

假设一个线程读取了一些数据并开始处理。 然后另一个线程做同样的事情。 两个线程都确定它们必须将结果写入文件 A 的位置 X。 充其量要写入的值是相同的,其中一个线程实际上什么也没做,只是浪费时间。 在最坏的情况下,其中一个线程的计算被覆盖,结果丢失。

您需要确定这是否会给您的应用程序带来问题。而且我必须指出,如果是这样,仅仅锁定读写操作并不能解决它。此外,尝试延长锁定的持续时间会导致其他问题。

选项

关键部分

是的,您可以使用临界区。

您需要选择关键部分的最佳粒度:每个文件一个,或者使用它们来指定文件中的特定块。 该决定需要更好地了解您的应用程序的用途,因此我不会为您解答。 请注意死锁的可能性: 线程 1 获得锁 A 线程 2 获得锁 B 线程 1 需要锁 B,但必须等待 线程 2 需要锁 A - 导致死锁,因为两个线程都无法释放其获得的锁。

我还将建议您在解决方案中考虑的其他 2 个工具。

单线程

说的太震撼了!但说真的,如果您使用多线程的原因是“让应用程序更快”,那么您使用多线程的原因是错误。大多数这样做的人实际上最终会使他们的应用程序更难编写、更不可靠并且更慢

多线程加速应用程序是一个非常普遍的误解。如果一个任务需要 X 个时钟周期来执行 - 它将需要 X 个时钟周期!多线程不会加速任务,它允许并行完成多个任务。但这可能是件坏事! ...

您已将您的应用程序描述为高度依赖于从磁盘读取、解析读取的内容并将其写入磁盘。根据解析步骤的 CPU 密集程度,您可能会发现所有线程的大部分时间都在等待磁盘 IO 操作。在这种情况下,多线程通常仅用于将磁盘磁头分流到(ummm round)磁盘盘片的较远“角落”。磁盘 IO 仍然是瓶颈,线程使其表现得好像文件被最大程度地碎片化了一样。

排队操作

假设您使用多线程的理由是正确的,并且您仍然有线程在共享资源上运行。您可以将共享资源操作排队到特定线程上,而不是使用锁来避免并发问题。

所以不是线程 1:

从文件 A 中读取位置 X 解析数据 写入文件 A 中的位置 Y

创建另一个线程; FileA 线程:

FileA 有一个指令队列 当它到达读取位置 X 的指令时,它会这样做。 它将数据发送到线程 1 线程 1 解析其数据 --- 而 FileA 线程继续处理指令 线程 1 放置一条指令,将其结果写入 FileA 线程队列后面的位置 Y --- 而 FileA 线程继续处理其他指令。 最终,FileA 线程将按照 Trhead 1 的要求写入数据。

【讨论】:

+1 表示single-threaded 背后的原因。尽管如此,当前的 I/O 系统可以轻松推动 100 Mb/s,并且线程可能一次读取数十兆字节的数据,从而将垃圾处理限制在更可忍受的范围内。或者可以实现生产者-消费者算法,只有一个线程做I/O,多个线程做解析和处理。【参考方案2】:

只有在多个代理对其执行某些操作时可能导致问题(或错误)的共享数据才需要同步。

如果您不希望其他写入程序进程在写入完成之前践踏新数据 - 文件如果您有一半的新数据被另一个看不到另一半新数据的进程修改(原始写入进程尚未写出),则可能不再一致。因此,您将拥有一组 CS,每个文件一个。写完之后应该尽快放出那个CS。

在某些情况下,例如内存映射文件或稀疏文件,操作系统可能允许您同时写入文件的不同部分。因此,在这种情况下,您的 CS 必须位于文件的特定 segment 上。因此,您将拥有每个文件的 CS 集合(每个段一个)。

如果同时写入和读取文件,读取器可能会得到不一致的数据。在某些 O/S 中,允许读取与写入同时发生(可能读取来自缓存的缓冲区)。但是,如果您正在写入文件并同时读取它,那么您读取的内容可能不正确。如果你需要一致的 reads 数据,那么 reader 也应该受到关键部分的约束。

在某些情况下,如果您正在写入一个段并从另一个段读取,O/S 可能会允许它。但是,这是否会返回正确的数据通常无法保证,因为您无法始终判断文件的两个段是否可能位于一个磁盘扇区或其他低级 O/S 事物中。

因此,一般来说,建议是将任何文件操作包装在一个 CS 中,每个文件。

理论上,您应该能够同时读取同一个文件,但将其锁定在 CS 中将只允许一个读取器。在这种情况下,您需要将您的实现分为“读锁”和“写锁”(类似于数据库系统)。这是非常重要的,因为您将不得不处理提升不同级别的锁。

注意后:您尝试数据的类型(读取和写入大小为 GB 的大型数据集同时分段)通常是在数据库中完成的。您应该考虑将数据文件分解为数据库记录。否则,您要么因锁定而遭受非优化的读/写性能,要么最终重新发明关系数据库。

【讨论】:

我编辑了我的问题。你能告诉我你对下面“编辑”的看法吗? "In most O/S's, you cannot write to the same file simultaneously." - 你有这个来源吗?我不是说这是错误的,只是不是我通过实验观察到的。也许我遇到了一个允许同时写入文件的操作系统。 好吧,我只是基于我熟悉的操作系统。没有什么真正科学的脸红。我相信 Windows 和 Unix 中的标准文件 I/O 调用允许您在读取时追加或同时追加。虽然有允许重叠写入的标志,但如果两个进程同时写入同一区域,则结果可能无法很好地定义。所以从技术上讲,我发表这种说法可能是错误的。我会编辑。【参考方案3】:

结论第一

您不需要TCriticalSection。您应该实现一个基于队列的算法,以保证没有两个线程正在处理同一数据段,而不会阻塞。

我是如何得出这个结论的

首先Windows(Win 7?)将允许您同时写入一个文件,只要您认为合适。我不知道它对写入有什么作用,而且我显然不是说这是个好主意,但我刚刚做了以下测试来证明 Windows 允许同时对同一个文件进行多次写入:

我创建了一个线程,它打开一个文件进行写入(使用“share deny none”)并将随机内容写入随机偏移量 30 秒。这是pastebin with the code。

为什么 TCriticalSection 会不好

临界区只允许一个线程在任何给定时间访问保护资源。您有两个选择:仅在读/写操作期间保持锁定,或者在处理给定资源所需的整个时间内保持锁定。两者都有严重的问题。

如果线程仅在读/写操作期间持有锁,可能会发生以下情况:

线程1获取锁,读取数据,释放锁 线程2获取锁,读取相同数据,释放锁 线程1完成处理,获取锁,写入数据,释放锁 线程 2 获得锁,写入数据,哎呀:线程 2 一直在处理旧数据,因为线程 1 在后台进行了更改!

如果一个线程为整个循环读取和写入操作持有锁,可能会发生以下情况:

线程 1 获取锁,开始读取数据 线程 2 尝试获取相同的锁,但被阻塞... 线程 1 完成读取数据,处理数据,将数据写回文件,释放锁 线程 2 获得锁并开始处理相同的数据再次

队列解决方案

由于您是多线程的,并且您可以让多个线程同时处理来自同一个文件的数据,我假设数据在某种程度上是“无上下文的”:您可以在处理文件的第 3 部分之前处理第 1 部分。这一定是真的,因为如果不是,你就不能多线程(或者每个文件限制为 1 个线程)。

在开始处理之前,您可以准备一些“作业”,如下所示:

文件“file1.raw”,偏移量 0,1024 Kb 文件“file1.raw”,偏移量 1024、1024 kb。 ... 文件“fileN.raw”,偏移量 99999999, 1024 kb

将所有这些“工作”放在一个队列中。让您的线程从队列中取出一个作业并处理它。由于没有两个作业重叠,线程不需要相互同步,所以你不需要临界区。您只需要关键部分来保护对队列本身的访问。 Windows 确保线程可以正常读写文件,只要它们坚持分配的“作业”。

【讨论】:

对于生产者-消费者的方法——是这样吗? --> docwiki.embarcadero.com/RADStudio/en/… 另外,我理解你的想法。对已读取的最后 500 个条目使用缓存。在读取之前检查缓存的读取过程。我已经完成了,抱歉我没有提到它。 没有。生产者-消费者是使用队列实现的。这是队列的 docwiki 链接:docwiki.embarcadero.com/CodeExamples/en/… 我编辑了我的答案,将示例代码移到了 Pastebin,因为它在这里占用了太多空间,并将通用的“生产者-消费者”文本替换为对事物(应该)如何工作的更详细的解释.只需重新阅读最后一部分,那是与队列相关的部分。

以上是关于什么时候应该使用临界区?的主要内容,如果未能解决你的问题,请参考以下文章

进程管理-进程互斥

uc/os进中断与进临界区有啥区别?

临界区和临界资源的关系

FreeRTOS临界区

从头认识java-18.5 临界区

如果使用同一个锁进入不同的临界区会发生啥