何时应该使用临界区?

5
这里是问题。我的应用程序有很多线程执行同样的任务 - 从大文件(>2GB)读取特定数据,解析数据并最终写入该文件。
问题在于,有时可能会发生一个线程从文件A中读取X,而第二个线程写入同一文件A的X。这会导致问题吗?
I/O代码对每个文件使用TFileStream。我将I/O代码拆分为本地(静态类),因为我担心会出现问题。既然已经拆分了,应该有关键段。
以下每种情况都是未实例化的本地(静态)代码。
procedure Foo(obj:TObject);
begin ... end;

案例二:

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

Case 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:在什么情况下需要使用临界区,以便多个线程同时调用时不会出现问题?

问题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;

如果两个线程使用相同的TSpecificFile调用Read会有问题吗?

情况1:相同的TSpecificFile

情况2:不同的TSpecificFile?

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


2
我的建议是:购买一本Joe Duffy的书Windows并发编程,并认真学习。你不能零零散散地学习并发编程。 - David Heffernan
关键部分不是您工具箱中唯一的线程编程工具。我很高兴您没有决定仅使用 TThread.Synchronize。正如David所说,您需要在正确设计多线程代码之前了解此主题的大量知识。这包括学习何时拆分设计。您是否考虑过有一个写入线程和一个处理器线程,并且将写入的结果仅排队,由单个写入线程全部写入? - Warren P
在启用并行I/O的系统上(例如RAID),仅有一个写入进程可能无法扩展。 - Stephen Chung
3个回答

7
一般来说,当多个线程可以同时访问共享资源,并且其中至少一个线程将写入/修改共享资源时,您需要一个锁定机制(关键部分是一种锁定机制)。这适用于内存中的对象或磁盘上的文件。锁定是必要的原因是,如果读操作与写操作同时发生,则读操作很可能会获取不一致的数据,导致不可预测的行为。关于文件处理方面,Stephen Cheung已经提到了特定于平台的注意事项,我在此不再赘述。
作为旁注,我想强调可能适用于您情况的另一个并发问题。
假设一个线程读取一些数据并开始处理。然后另一个线程也这样做。两个线程都确定它们必须将结果写入文件A的位置X。最好要写入的值相同,其中一个线程实际上只是浪费时间。最坏的情况是,其中一个线程的计算被覆盖,结果丢失。您需要确定这是否对应用程序造成问题。我必须指出,如果是这样,仅锁定读取和写入操作将无法解决它。此外,尝试延长锁定的持续时间会导致其他问题。
选项
关键部分
是的,您可以使用关键部分。
您需要选择关键部分的最佳粒度:每个整个文件一个,或者使用它们指定文件中的特定块。决策需要更好地了解您的应用程序所做的事情,因此我不会为您回答。只需注意死锁的可能性:
线程1获取锁A
线程2获取锁B
线程1需要锁B,但必须等待
线程2需要锁A - 由于没有线程能够释放其已获得的锁定而导致死锁。
我还要建议您在解决方案中考虑其他2个工具。
单线程
说出来多么惊人!但是,如果您采用多线程的原因是“使应用程序更快”,那么您采用多线程是出于错误的原因。大多数这样做的人最终会使他们的应用程序更难编写、不太可靠且更慢!
普遍存在一种误解,即多个线程可以加速应用程序。如果一个任务需要X个时钟周期来执行,它将需要X个时钟周期!多个线程不能加速任务,它允许并行完成多个任务。但这可能是件坏事!...
您将应用程序描述为高度依赖于从磁盘读取、解析已读内容并写入磁盘。根据解析步骤的CPU密集程度,您可能会发现所有线程大部分时间都在等待磁盘IO操作。在这种情况下,多个线程通常只是将磁盘头移动到(嗯,圆形)磁盘盘片的远“角落”。磁盘IO仍然是瓶颈,而线程使其表现得好像文件已最大碎片化。
排队操作
假设您采用多线程的原因是有效的,并且您确实有线程在共享资源上运行。不要使用锁定来避免并发问题,而是将共享资源操作排队到特定线程上。
因此,不再使用Thread 1:
  • 从文件A读取位置X
  • 解析数据
  • 写入文件A中的位置Y
创建另一个线程;FileA线程:
  • FileA具有指令队列
  • 当它到达读取位置X的指令时,它执行该指令。
  • 它将数据发送给Thread 1
  • Thread 1解析其数据,而FileA线程继续处理指令
  • Thread 1在FileA线程的队列末尾放置一个指令,要求将其结果写入位置Y --- 而FileA线程继续处理其他指令。
  • 最终,FileA线程将按Thread 1的要求写入数据。

2
+1 是因为“单线程”的原因。尽管如此,当前的I/O系统可以轻松推送100 Mb/s,而线程可能一次读取数十兆字节的数据,将垃圾限制在可承受范围内。或者可以实现生产者-消费者算法,其中仅有一个线程进行I/O,多个线程进行解析和处理。 - Cosmin Prund

5
同步只有在共享数据可能引起问题(或错误)时才需要,如果多个代理程序同时对其进行操作,则会导致问题。显然,如果你不希望其他写入进程在写入完成之前破坏新数据,那么文件写入操作应该仅包装在一个临界区域中,仅针对该文件。因此,你将拥有一组临界区域,每个文件一个。当你完成写入后,应尽快释放该临界区域。
在某些情况下,例如内存映射文件或稀疏文件,操作系统可能允许你同时向文件的不同部分写入。因此,在这种情况下,你的临界区域必须位于文件的特定段上。因此,每个文件都将有一组临界区域(每个段一个)。
如果你同时写入文件并读取它,则读取器可能会获取不一致的数据。在某些操作系统中,读取可以与写入同时发生(也许读取来自缓存缓冲区)。但是,如果你同时写入文件并读取它,则你读取的内容可能不正确。如果需要读取一致的数据,则读取器也应受到临界区域的限制。
在某些情况下,如果你正在写入一个段并从另一个段读取,则操作系统可能允许它。但是,通常无法保证这将返回正确的数据,因为你无法始终确定文件的两个段是否可能驻留在一个磁盘扇区或其他低级操作系统事物中。
因此,总的建议是将任何文件操作都包装在临界区域内,每个文件一个。
理论上,你应该能够同时从同一文件中读取,但锁定它在临界区域内只允许一个读取器。在这种情况下,你需要将实现分成“读取锁”和“写入锁”(类似于数据库系统)。然而,这非常不容易,因为你随后必须处理提升不同级别的锁。
附注:你正在尝试处理的数据类型(同时在段中读取和写入大型数据集)通常是在数据库中完成的。你应该考虑将数据文件拆分为数据库记录。否则,由于锁定,你要么遭受非优化的读/写性能,要么最终重新发明关系数据库。

我编辑了我的问题。你能告诉我你对下面的“EDIT”有什么看法吗? - pop32
“在大多数操作系统中,您无法同时向同一文件写入。” - 你有这方面的来源吗?我不是说它是错的,只是它不是我实验观察到的情况。也许我遇到了唯一允许同时写入文件的操作系统。 - Cosmin Prund
嗯,我只是基于我熟悉的操作系统来说。没有什么真正科学的依据 害羞。我相信Windows和Unix中的标准文件I/O调用允许您在读取时进行追加,或同时进行追加。虽然有一些标志允许重叠写入,但如果两个进程同时写入同一区域,则结果可能无法定义。所以从技术上讲,我在做这种陈述时可能是错误的。我会进行编辑。 - Stephen Chung

3

首先得出结论

你不需要使用TCriticalSection。你应该实现一个基于队列的算法,可以保证没有两个线程在处理相同的数据,而不会造成阻塞。

我是如何得出这个结论的

首先,在Windows(Win 7?)中,你可以同时写入文件多次。我不知道它对这些写操作做了什么,也显然不是说这是个好主意。但我刚刚做了如下测试来证明Windows允许同时对同一文件进行多次写操作:

我创建了一个线程,以“share deny none”方式打开文件并在随机偏移量处持续写入随机内容30秒钟。以下是包含代码的Pastebin连接

为什么TCriticalSection会有问题

临界区只允许一个线程同时访问受保护的资源。你有两个选择:只在读写操作的持续时间内保持锁定,或者在处理给定资源所需的整个时间内保持锁定。这两种方法都存在严重问题。

如果一个线程只在读写操作的持续时间内保持锁定,可能会出现以下情况:

  • 线程1获取锁定,读取数据,释放锁定
  • 线程2获取锁定,读取相同的数据,释放锁定
  • 线程1完成处理,获取锁定,写入数据,释放锁定
  • 线程2获取锁定,写入数据,这里发生了糟糕的事情:线程2一直在使用旧数据,因为线程1在后台进行了更改!

如果一个线程将锁定保持整个“往返”读写操作的时间,可能会出现以下情况:

  • 线程1获取锁定,开始读取数据
  • 线程2试图获取相同的锁定,被阻止......
  • 线程1完成读取数据,处理数据,将数据写回文件,并释放锁定
  • 线程2获取锁定并开始再次处理相同的数据!

队列解决方案

由于你正在进行多线程处理,且可以有多个线程同时从同一文件中处理数据,我假设数据在某种程度上是“无关上下文”的:你可以在处理文件的第三部分之前处理第一部分。这必须是真实的,因为如果不是这样,你就无法进行多线程处理(或者每个文件只能限制一个线程)。

在开始处理之前,你可以准备一些“作业”,看起来像这样:

  • 文件'file1.raw',偏移量为0,1024 Kb
  • 文件'file1.raw',偏移量为1024,1024 kb。
  • ...
  • 文件'fileN.raw',偏移量为99999999,1024 kb

将所有这些“任务”放入队列中。让您的线程从队列中出队一个Job并处理它。由于没有两个作业重叠,线程不需要相互同步,因此您不需要关键部分。您只需要关键部分来保护对队列本身的访问。只要它们坚持分配的“工作”,Windows就可以确保线程可以很好地读取和写入/从文件中读取和写入。


针对生产者-消费者方法,是指这个吗?-->http://docwiki.embarcadero.com/RADStudio/en/Using_the_Multi-read_Exclusive-write_Synchronizer - pop32
另外,我明白你的想法。使用缓存来存储最近读取的500个条目。在进行读取操作之前,先检查缓存。这一点我已经完成了,抱歉之前没有提到。 - pop32
不,生产者-消费者模式是使用队列实现的。这里有一个关于队列的文档链接:http://docwiki.embarcadero.com/CodeExamples/en/Generics_Collections_TQueue_(Delphi) - Cosmin Prund
我编辑了我的回答,将示例代码移动到Pastebin,因为它在这里占用了太多的空间,并用更详细的解释替换了通用的“生产者-消费者”文本,重新阅读最后一节,那是有关队列的内容。 - Cosmin Prund

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接