理解并避免多线程 C# 应用程序中的竞争条件
何时需要担心竞争条件
在现代应用程序中,通常在同一时刻执行多个指令序列。这些指令序列称为线程。除了最简单的应用程序外,所有应用程序都有多个线程,因此了解多线程应用程序中运行时可能发生的情况非常重要。
在某些情况下,即使应用程序是多线程的,开发人员可能只需要担心单个线程。例如,在 .NET 中,垃圾收集发生在单独的线程上,但开发人员可能不需要过多考虑这一事实。然而,开发人员启动自己的线程,在“后台”执行一些工作是很常见的。这些情况下最常出现竞争条件。
您可能已经猜到了,竞争条件不是开发人员编写的代码或明确允许的。相反,竞争条件可能发生在缺乏适当保护措施的多线程应用程序中。最常见的情况是,防止竞争条件需要同步来自多个线程的数据访问。
同步数据访问的案例
为了理解数据同步的必要性,让我们看一个例子:假设您正在编写一个网络爬虫控制台应用程序,该应用程序下载特定 URL 的 HTML 并将找到的链接(例如< a href="/path/to... )写入文件(例如links.txt)。在真正的网络爬虫样式中,应用程序然后下载每个链接的 HTML,并继续递归,直到达到某个限制,或者直到已检索/处理所有链接的 HTML。
同步执行此操作会非常慢,因为应用程序必须等待一个链接的 HTML 下载完毕,然后才能开始下一个链接的请求。因此,为了加快速度,您决定通过为每个链接请求使用单独的线程来异步执行此操作。这种网络爬虫的简单实现可能如下所示:
const int MaxLinks = 8000;
const int MaxThreadCount = 10;
string[] links;
int iteration = 0;
// Start with a single URL (a Wikipedia page, in this example).
AddLinksForUrl("https://en.wikipedia.org/wiki/Web_crawler");
while ((links = File.ReadAllLines("links.txt")).Length < MaxLinks)
{
int offset = (iteration * MaxThreadCount);
var tasks = new List<Task>();
for (int i = 0; i < MaxThreadCount && (offset + i) < links.Length - 1; i++)
{
tasks.Add(Task.Run(() => AddLinksForUrl(links[offset + i])));
}
Task.WaitAll(tasks.ToArray());
iteration++;
}
AddLinksForUrl如下所示:
static void AddLinksForUrl(string url)
{
string html = /* retrieve the html for said url */ ;
List<string> links = /* extract the links from the html */ ;
using (var fileStream = new FileStream("links.txt",
FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
List<string> existingLinks = /* read the file contents */ ;
foreach (var link in links.Except(existingLinks))
{
fileStream.Write(/* the link URL, as bytes, plus a new line */);
}
}
}
主算法中要注意的关键点是每次调用Task.Run时都会启动一个新线程。由于我们将MaxThreadCount定义为 10,因此将启动 10 个线程,然后Task.WaitAll将等待,直到所有这些线程中的工作完成。之后,在while循环的下一次迭代中将启动一批新线程。
完全实现后,这个网络爬虫实际上可能工作正常。但是如果你运行它足够多次,你最终会得到一个IOException。这是为什么呢?
System.IO.IOException:该进程无法访问文件“/path/to/links.txt”,因为该文件正在被另一个进程使用。
请注意,在AddLinksForUrl中,我们使用FileShare.None来获取对links.txt的独占访问权限。这是正确的,因为多个进程同时写入同一个文件可能会导致问题,包括数据损坏。根据 Web 服务器的响应时间以及下载 HTML 所需的时间,我们的 Web 爬虫有时可能会有多个线程尝试同时打开links.txt 。因此,我们需要同步对links.txt文件的访问,以使它永远不会同时从多个线程发生。线程之间共享的任何数据都需要这种同步。
数据访问同步的简单方法
考虑一下同步访问共享数据的最直接尝试——布尔标志。我们可以在打开文件时将标志设置为 true,在完成后将其设置为 false,然后在尝试打开文件之前检查该标志。这应该可以解决问题,对吧?
static bool fileIsInUse;
static void AddLinksForUrl(string url)
{
...
while (fileIsInUse)
{
System.Threading.Thread.Sleep(50);
}
try
{
fileIsInUse = true;
using (FileStream fileStream = new FileStream("links.txt"...))
{
...
}
}
finally
{
fileIsInUse = false;
}
}
确实如此,这种方法可以在一定程度上同步对链接文件的访问。但运行多次后,最终您会得到另一个IOException。本质上,同样的问题仍然存在,但为什么呢?
请记住,我们有多个线程同时执行AddLinksForUrl中的代码。我们使用这种幼稚方法所犯的错误是,我们无法保证每次只有一个线程将fileIsInUse标志设置为 true。因此,在finally块中将fileIsInUse设置为 false 的那一刻,多个线程可能正在上面的while循环中等待。如果在fileIsInUse为 false时,多个线程同时(或几乎同时)跳出while循环,它们都将进入try块,并且它们都将认为自己对文件具有独占访问权限。在这种情况下,将发生IOException。这种异常是竞争条件的一个例子。
由于编译器将单个 C# 指令转换为多个机器级指令,竞争条件可能特别隐蔽。这意味着 C# 中看似连续的代码行实际上可能被相应机器代码中的相当多指令分隔开。如果我们没有为代码的关键部分设置保证,则运行时跨线程的实际执行顺序可能与我们的预期不符。简而言之,当顺序很重要时,我们不能听天由命。对于共享数据,任何时候线程需要独占访问,我们都需要保证这种独占访问。
我们从这种幼稚方法的失败中学到的最后一件事是,多线程上下文中的“共享数据”不仅仅指文件。不,实际上它指的是跨线程共享的任何东西,包括变量 - 无论是值类型(如上例中的布尔值)还是引用类型。
同步访问的正确方法
现在我们知道我们需要保证在多线程应用程序中同步访问共享数据(为了避免竞争条件),我们实际上如何实现这一点?好吧,C# 和 .NET Framework 提供了许多方法来实现这一点,但最简单和最常见的方法之一是使用 lock 语句。本系列的下一篇指南将详细探讨 lock 语句。
免责声明:本内容来源于第三方作者授权、网友推荐或互联网整理,旨在为广大用户提供学习与参考之用。所有文本和图片版权归原创网站或作者本人所有,其观点并不代表本站立场。如有任何版权侵犯或转载不当之情况,请与我们取得联系,我们将尽快进行相关处理与修改。感谢您的理解与支持!
请先 登录后发表评论 ~