C# 中的多线程:回归基础(第一部分)
探讨多线程及相关主题。
引言
本系列的第一篇文章《回归基础》将讨论与多线程相关的概念,为后续文章打下基础。与我撰写的任何文章一样,如果您发现错误或认为我遗漏了重要内容,请留言!
由于本文将是一个关于低级细节的速成课程,为后续文章铺路,因此我将跳过大量非必需的信息。我将在适当的地方提供进一步阅读的链接。
目录
注意:随着本系列更多文章的发布,目录将进行更新。
CPU、操作系统和 CLR
让我们看一下 Core i5 3570K 和 Core i7 7700T。我们主要关心的是电子表格中称为“Threads”(线程)的内容,它代表逻辑 CPU 核心的数量。“Cores”(核心)在电子表格中代表物理 CPU 核心的数量。核心是执行指令的处理单元。有关 i7 的硬件线程数为何高于核心数,请参阅 超线程。
在操作系统中,内核是控制核心功能(如线程、进程、内存、I/O 和调度管理)的中心单元——实际上,它控制着 OS 中发生的一切。当您的可执行文件运行时,OS 会为您的应用程序创建一个进程 内核对象。进程在某种程度上是一个隔离的数据容器。它包含应用程序的所有必要信息,包括虚拟地址空间和线程。每个进程至少包含一个线程内核对象。这些内核线程会被调度到 CPU 核心进行处理。每个被调度的内核线程都会获得一个时间片来执行。在以下情况下,内核可以中断此执行:
- 线程的时间片已过期。
- 优先级更高的线程变得可调度。
- 线程由于 I/O 等任务而被阻塞并让出其时间片。
当发生中断时,内核会保存线程的状态,然后调度下一个就绪的线程,这被称为上下文切换。如果被切换的线程不属于同一进程,还需要切换到新进程的地址空间。
创建进程后,OS 会将 CLR 加载到进程的地址空间,因为这是 可执行文件头 中指示的 .NET 应用程序。CLR 通过加载域无关 DLL(如 MSCorLib.dll
)到进程、为应用程序创建默认 AppDomain
,并加载 程序集 和所需的 DLL 到 AppDomain
来设置应用程序的 .NET 环境。一旦所有这些设置完成,应用程序就可以执行了。
简而言之,应用程序运行在由 CLR 管理的 AppDomain
中,CLR 运行在由 OS/内核管理的进程中。
线程和 AppDomain
在托管环境中,所有线程都由 Thread
对象表示。这些被称为用户线程或托管线程。托管线程可以根据 CLR 和外部因素与内核线程进行 1:1、N:1 或 N:M 的映射。
引用:MSDN操作系统 **ThreadId** 与托管线程没有固定的关系,因为非托管主机可以控制托管线程和非托管线程之间的关系。
了解这一点很有用,但了解默认行为也可能很有帮助,因此我们将使用 Windows API 和 Thread
信息进行一些比较,看看您在 C# 中访问多线程的主要方式——Thread
、ThreadPool
和任务并行库。
[StructLayout(LayoutKind.Sequential, Pack = 2)]
public struct SystemTime
{
public ushort Year;
public ushort Month;
public ushort DayOfWeek;
public ushort Day;
public ushort Hour;
public ushort Minute;
public ushort Second;
public ushort Milliseconds;
public string ToHMSM() =>
$"{Hour}:{Minute}:{Second}:{Milliseconds,3}";
}
class Program
{
[DllImport("Kernel32.dll", CallingConvention = CallingConvention.StdCall)]
public static extern uint GetCurrentThreadId();
[DllImport("Kernel32.dll", CallingConvention = CallingConvention.StdCall)]
public static extern void GetLocalTime(out SystemTime time);
static void Main(string[] args)
{
int threadNumber = 10;
Stopwatch sw = new Stopwatch();
Console.WriteLine("Starting Threads...");
Console.WriteLine("CLR ThreadID OS ThreadID Time");
Console.WriteLine("------------ ----------- ----");
CountdownEvent countdownEvent = new CountdownEvent(threadNumber);
sw.Start();
for (int i = 0; i < threadNumber; i++)
new Thread(() =>
{
SystemTime time;
GetLocalTime(out time);
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId, 12} {GetCurrentThreadId(), 11:X} {time.ToHMSM()}");
countdownEvent.Signal();
}).Start();
countdownEvent.Wait();
sw.Stop();
Console.WriteLine($"Total run time (ms): {sw.ElapsedMilliseconds}");
Console.WriteLine("\nStarting ThreadPool threads...");
Console.WriteLine("CLR ThreadID OS ThreadID Time");
Console.WriteLine("------------ ----------- ----");
countdownEvent.Reset();
sw.Reset();
sw.Start();
for (int i = 0; i < threadNumber; i++)
ThreadPool.QueueUserWorkItem(_ =>
{
SystemTime time;
GetLocalTime(out time);
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId,12} {GetCurrentThreadId(),11:X} {time.ToHMSM()}");
countdownEvent.Signal();
});
countdownEvent.Wait();
sw.Stop();
Console.WriteLine($"Total run time (ms): {sw.ElapsedMilliseconds}");
Console.WriteLine("\nStarting Tasks...");
Console.WriteLine("CLR ThreadID OS ThreadID Time");
Console.WriteLine("------------ ----------- ----");
Task[] tasks = new Task[threadNumber];
sw.Reset();
sw.Start();
for (int i = 0; i < threadNumber; i++)
tasks[i] = Task.Run(() =>
{
SystemTime time;
GetLocalTime(out time);
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId,12} {GetCurrentThreadId(),11:X} {time.ToHMSM()}");
});
Task.WaitAll(tasks);
sw.Stop();
Console.WriteLine($"Total run time (ms): {sw.ElapsedMilliseconds}");
Console.WriteLine("\nStarting TPL tasks...");
Console.WriteLine("CLR ThreadID OS ThreadID Time");
Console.WriteLine("------------ ----------- ----");
sw.Reset();
sw.Start();
Parallel.For(0, threadNumber, _ =>
{
SystemTime time;
GetLocalTime(out time);
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId,12} {GetCurrentThreadId(),11:X} {time.ToHMSM()}");
});
sw.Stop();
Console.WriteLine($"Total run time (ms): {sw.ElapsedMilliseconds}");
Console.ReadKey();
}
}
默认情况下,Thread
和内核线程之间似乎存在 1:1 的关系。不要过分看重运行时间。我将在未来的文章中讨论每个对象时进行更严格的比较。由于 CountdownEvent
等因素,并非所有这些运行都严格等同。然而,总体时间确实表明,在少量线程上管理短时任务比为每个任务创建唯一线程更有效。
现在我们理解了内核线程和托管 Thread
之间的关系,那么进程和 AppDomain
之间的关系是什么?进程的目的是隔离应用程序环境。由于所有托管代码都通过 CLR,因此它可以对代码做出某些保证。这使得 .NET 可以使用轻量级进程或 AppDomain
来隔离应用程序。在单个进程中运行的 AppDomain
之间的上下文切换不需要内核(也称为系统)调用——它完全由 CLR 管理。进程创建和上下文切换耗时,因此使用 AppDomain
可以避免这些昂贵的操作。这对空间和时间开销来说是双赢。AppDomain
也有助于卸载单个程序集,因为包含被卸载程序集的所有域也必须被卸载。为程序集创建新的 AppDomain
允许您将来卸载它,而无需卸载主 AppDomain
及其包含的其他程序集。
总而言之,Thread
是内核线程的轻量级、托管版本。AppDomain
是进程的轻量级、托管版本。它们基本上是操作系统对应物的托管抽象,但存在细微的差异,例如内核线程归其进程所有,而 Thread
可以跨越 AppDomain
。
并发、并行和异步
当存在多个线程时,需要一种处理执行的策略。一个核心一次只能执行一个线程。虽然现代计算机有多个核心,但这仍然不足以在保持响应的同时处理计算机上正在执行的所有线程。并发是处理这种情况的一种策略。并发是指多个线程在同一时间段内执行。注意我说了时间段。当线程同时执行时,这称为并行,它是并发的一个子集。
并发的实现通常是一种称为上下文切换的技术,该技术在本文的第一部分进行了讨论。这在上面的左图中有表示。每个线程都有一个执行时间,之后线程的状态被保存,执行上下文被切换到另一个线程。这可以防止单个线程耗尽其他线程的 CPU 时间。异步是指不被强制同步执行——惊喜!异步执行的内容可以在阻塞操作(如等待数据发送)期间继续执行其他任务,然后在稍后恢复阻塞任务。这并不意味着多线程。异步的实现可以是多线程的,但异步概念本身并不需要。当处理可能出现延迟的外部系统时,异步行为很有用。
例如,想象一下填写一份表格,例如驾驶执照表格。您索取表格,然后在等待表格的同时查看手机。您(线程 #1)通过执行其他任务来异步等待表格,直到表格可用。您身边有另一个人等待不会有任何帮助。现在您终于拿到表格并正在填写。如果另一个人(线程 #2)在帮忙,您就会同时填写表格。如果您身边有另一个人帮忙填写,将会缩短所需时间。这当然是一个牵强的例子,但我希望它能说明问题。
内存
目标是尽可能少地共享数据。数据在线程中本地化的程度越高,出错的空间就越少。但这通常不是一个选项,因此了解内存的工作原理以及需要防范什么很重要。那么在这种情况下会发生什么?
static void Main(string[] args)
{
int a = 0, b = 1;
Random rnd = new Random();
Parallel.For(0, 10, index => Parallel.Invoke(
() =>
{
a = rnd.Next(100);
b = rnd.Next(100);
Console.WriteLine($"Set {a}, {b} on Thread {Thread.CurrentThread.ManagedThreadId},
Iteration {index}\n");
},
() =>
{
Console.WriteLine($"Read {a}, {b} on Thread {Thread.CurrentThread.ManagedThreadId},
Iteration {index}\n");
}));
Console.ReadKey();
}
这是一个执行示例
我在每个 Console.WriteLine
上放置了两个跟踪点。有些问题应该会立即显现出来。首先,即使两个输出都在同一语句上,读写操作的顺序在输出之间也是相反的。其次,读写操作报告的值并不相同。例如,查看每个输出的最后三次读取。控制台报告两次 33,91,而跟踪点报告两次 34,96。仔细查看这些结果会发现更糟糕的事情。查看控制台输出的第 8 行和第 9 行(线程 11/迭代 3 和线程 10/迭代 6)。现在查看控制台的写操作和跟踪点输出。
还有第三个问题。根据读写操作的顺序和竞争条件,数据可能会丢失。实际上,控制台和跟踪点输出有 7 个不同的数据集,每个数据集在另一个数据集中不出现(36,79 和 94,30),而本应生成 10 个数据集。我们在每个输出中丢失了 30% 的数据集(总计 20%),包括读写操作。如果仅考虑读取操作,情况会更糟。这里出了什么问题?
编译器优化
编译器和 JIT 编译器可以随意优化和重新排列代码,只要不影响**单线程**执行行为即可。实际上,许多优化并不经常发生,但出于调试目的,了解它们仍然是一个好主意。
读取优化
这可以以多种形式出现。读取可以完全消除,或者读取可以被复制。对于读取被消除的情况,请看这些例子
int a = 1;
int b = a;
//becomes
int a = 1;
int b = 1;
int c = 1;
c = 2;
//becomes
int c = 2;
对于读取被复制的例子
public void Test(Object obj)
{
Object obj2 = obj;
if (obj2)
Console.Write(obj2);
}
//becomes
public void Test(Object obj)
{
if (obj)
Console.Write(obj);
}
经过优化后,obj
被读取了三次,而不是两次。现在,如果 obj
在条件判断后被修改,代码可能会写入 null
,而在优化前的示例中,由于本地分配给 obj2
,这种情况不会发生。
循环不变代码提取
当循环内的代码不依赖于循环头或循环体时,就会发生这种情况。
int x = 10;
int count = 0;
for (int i = 0; i < x; i++)
{
count++;
//Other stuff
}
//becomes
int x = 10;
int count = 0;
for (int i = 0; i < x; i++)
{
//Other stuff
}
count = x;
实际上,编译器或 JIT 编译器甚至可能通过简单地进行 int count = x
或 int count = 10
来进一步优化它。
其他优化包括循环展开(在循环体内重复循环代码以避免循环条件检查)和函数内联(将函数代码直接复制到调用它的地方)。我确信还有其他我忘记了或根本不知道的。我甚至没有提及 JIT 编译器在运行时可以进行的优化,例如删除整个语句,比如一个在应用程序实例中始终为真的 if
语句。然而,在实践中,最常见的优化将是语句重排序。经典的例子是
int x = 0;
bool isCompleted = false;
public void Func1()
{
x = 10;
isCompleted = true;
}
public void Func2()
{
if (isCompleted)
Console.WriteLine(x);
}
如果 Func1
和 Func2
在不同的线程上执行,Func2
是否有可能将 0 写入控制台?是的,如果 isCompleted = true
在 x = 10
之前移动并且 isCompleted = true
被执行,那么 Func2
将在 x = 10
之前完全执行。
CPU 重排序
这个话题很难写得不写一本小书,因为许多细节在 x86/x86-64、ARM 和 Itanium 等不同架构之间差异很大。它们在指令集、内存模型和物理架构方面都存在差异。存在许多可能存在或不存在的东西,例如存储缓冲区、加载缓冲区、写合并缓冲区、无效队列和内存排序缓冲区,仅举几例。然后还有各种控制器、总线和点对点互连,它们在不同架构之间也不同。由于这是一个 C# 系列,我将只讨论影响我们应用程序的常见概念。如果需要更多信息,通常可以在供应商网站上找到有关每个架构的详细信息——例如 Intel64 和 IA-32 开发者手册。
缓存
缓存是 CPU 上速度极快的内存。其速度源于多种因素,例如与核心的接近度、RAM 类型(SRAM)以及不必通过 前端总线 或内存总线。在现代 CPU 中,通常有多个缓存。有些缓存由 CPU 核心共享,有些则专用于特定核心。缓存内存被分成称为缓存行的数据块。这些缓存行包含数据以及其他信息,例如数据在主内存(主板上的内存插槽)中的地址。当核心从主内存请求数据时,首先检查缓存是否包含该数据。如果没有,缓存将从主内存中获取数据。从这个描述可以看出,核心主要处理缓存以减少延迟。但如果每个核心主要处理自己的缓存,那么当缓存最终需要将新数据写回主内存时,如何管理数据的一致性?多个缓存可能正在处理同一主内存地址。
缓存一致性协议
为了处理这个 缓存一致性 问题,引入了缓存一致性协议。基本思想是每个缓存行都将带有一个状态。以 MESI 协议为例,每个缓存行可以处于已修改、独占、共享或无效状态。根据当前状态以及对缓存行执行的操作,可以通过 总线 发送各种信号,以便缓存可以确保它们之间的一致性。这将确保如果主内存中的某些数据被加载到一个缓存中,然后另一个缓存尝试加载相同的数据,第一个缓存将服务于加载请求,并且两个缓存都知道它们不持有唯一的副本(共享状态)。如果其中一个缓存随后尝试写入此数据,它会因共享状态而知道需要通知其他缓存使其包含此数据的缓存行无效。这种通信确保当数据写回主内存时,执行写入的缓存是唯一已修改的副本。写入主内存的确切时间称为缓存的 写策略。
关键在于,缓存之间的一致性是由硬件处理的。这通常不是软件开发人员需要处理的问题,除非架构未实现缓存一致性协议,或者协议引入了某种重排序,在这种情况下,重排序很可能已经通过处理开发人员在多线程环境中面临的主要问题——乱序执行——来处理了。
乱序执行
如果等待内存数据等延迟发生,按顺序执行每条指令都会浪费宝贵的指令周期。这就是 CPU 被允许重新排序指令执行的原因。但我们实际上并不直接关心这种执行重排序,因为指令结果会被排队并根据架构的 内存顺序 进行重新排序。我们关心的是这种结果/内存重排序。有四种不同的内存顺序
- 加载-加载:加载可以与其他加载重新排序。
- 加载-存储:加载可以与后续存储重新排序。
- 存储-加载:存储可以与后续加载重新排序。
- 存储-存储:存储可以与其他存储重新排序。
不同的架构对允许重新排序的内容有不同的保证。据我所知,没有架构允许会影响单线程行为的任何重排序——Alpha 在处理数据依赖性时可能是例外。如果您知道,请留言!这意味着对同一地址(数据)的操作不会被重新排序,如果它会改变单线程环境中的结果。问题在于当涉及多个线程时。例如,在 x86 上
;Initial state: x=y=0
;Core #1
mov [x], 1 ;store
mov a, [y] ;load
;Core #2
mov [y], 1 ;store
mov b, [x] ;load
由于交错,有人可能会认为此示例会产生 `[a=b=1]`、`[a=0,b=1]` 或 `[a=1,b=0]` 的结果。然而,实际上还有第四种选择。x86 允许(实际上只允许)存储-加载内存重排序。这意味着两个加载都可以移到存储之前,导致 `[a=b=0]`。由于每个核心上的每个单独指令都位于不同的地址,因此这是允许的。然而,以下内容返回了您期望的结果:
;Initial state: x=y=0
;Core #1
mov [x], 1 ;store
mov a, [x] ;load
;Core #2
mov [y], 1 ;store
mov b, [y] ;load
这唯一的结果是 `[a=b=1]`。请注意,现在每个核心都操作于单个地址,因此没有重排序,因为这会影响单线程行为。
**题外话:** 实际上,由于存储可能在缓冲区中延迟,因此在此示例中仍有可能发生一些重排序。阻止应用程序看到这种细微的重排序被称为存储到加载转发。
虽然存储暂时保存在处理器的存储缓冲区中,但它可以满足处理器自身的加载,但对其他处理器的加载是不可见的(也无法满足)。
内存屏障
内存屏障(又称栅栏)允许开发人员指定编译器和/或 CPU 内存重排序不应发生的位置。这就是我们将从硬件转向 C# 的地方。原因是 C# 没有对汇编的原生支持,因此汇编屏障对于 C# 开发人员来说很大程度上是无关紧要的,除非您使用 VC++ DLL 或 C++/CLI 组合出某些东西。此外,CLR 屏障可以防止编译器和 JIT 编译器重排序,为什么不使用它们呢?CLR 屏障提供三种不同的语义:
- 获取:读取/获取屏障确保后续内存操作不会在之前的读取之前执行。
- 释放:写入/释放屏障确保之前的内存操作不会在后续写入之后执行。
- 内存/全:全屏障结合了获取和释放语义,防止任何执行移动。
“...在之前的读取之前”和“...在后续写入之后”的区别非常重要。一些书籍和在线来源会将它们描述为“...在屏障之前/之后”。这种行为将是危险的不正确的。
someObject.SomeValue = 3; <write_barrier> someObject.SomeValue = 5;
在此示例中,没有什么可以阻止 someObject.SomeValue = 5
在 someObject.SomeValue = 3
之前移动,如果屏障仅阻止从屏障之前到之后的重排序。这就是为什么真正的区别很重要。由于屏障实际上阻止了从屏障之前到后续写入之后的重排序,因此无法进行这种重排。
因此,由于 <write_barrier>
显然不是真正的 C# 代码,C# 实际暴露了哪些屏障原语?有五种:Volatile.Read
、Volatile.Write
、Thread.VolatileRead
、Thread.VolatileWrite
和 Thread.MemoryBarrier
。MemoryBarrier
是一个全屏障。它根据上述“全”规则,在两个方向上防止编译器、JIT 编译器和 CPU 重排序。Volatile.Read
根据上述“获取”规则防止重排序,而 Volatile.Write
根据“释放”规则防止重排序。
Thread.VolatileRead
和 Thread.VolatileWrite
有点不同。它们最初是一个错误。它们使用 MemoryBarrier
来防止重排序,这提供了比实际需要更强的保证(因此开销更大)。这就是为什么实现了 Volatile.Read
和 Volatile.Write
来为 volatile 语义提供正确的实现。在多线程中,您的意图需要清晰地在代码中表达,否则您将面临难以调试和/或代码难以维护的风险,因此我的建议是,如果您可以访问 .NET 2.0 中包含的 Volatile.Read
/Write
,请不要使用 Thread.VolatileRead
/Write
。
您会注意到我没有提到 volatile
关键字。我建议永远不要使用它。它不仅将行为模糊化到**类型**定义中,而且其确切语义尚不稳定。例如,一些旧文档会说 volatile
被转换为 Thread.VolatileRead
/Write
调用,而实际上它现在被转换为 Volatile.Read
/Write
[^]。如果将来他们再次更改此设置,您的代码可能会一夜之间中断,而原因却不易察觉。
如果仅针对特定操作系统进行工作,那么该操作系统 API 公开的同步原语(如 Windows API 同步函数)也可以使用。
其他说明
我之前避免谈论这一点,但架构提供的内存屏障通常比您的语言或框架提供的内存屏障有不同的保证。通常,语言屏障是架构屏障的组合。例如,这是 x86 上内存屏障的保证:
- 加载不能通过先前的 lfence 或 mfence。
- 存储不能通过先前的 lfence、sfence 或 mfence。
- lfence 不能通过先前的加载。
- sfence 不能通过先前的存储。
- mfence 不能通过先前的加载或存储。
另外,在 C++11 等某些语言中,获取/释放操作与获取/释放屏障之间可能存在区别。操作保证相对于单个操作的获取/释放语义,而屏障保证相对于所有相关读取/写入的语义。
奖励
我在别处看到一个关于 Thread.VolatileRead
/Write
具体实现的问答,其中问题的某个部分被所有回复忽略了,所以我想这会是一个有趣的奖励部分来回答!让我们看看 Thread.VolatileRead(int)
[MethodImpl(MethodImplOptions.NoInlining)]
public static int VolatileRead(ref int address)
{
int num = address;
MemoryBarrier();
return num;
}
问题中被忽略的部分围绕着为什么使用 [MethodImpl(MethodImplOptions.NoInlining)]
。答案是函数调用提供了自己的排序保证。编译器无法假定函数调用的副作用,因此无法围绕调用进行重排序。考虑到函数调用可能存在于外部库中,其对内存的影响是未知的,这一点就完全说得通了。然而,如果函数被内联,那么编译器就确切地知道将执行哪些操作,并且可以进行优化。由于此函数设计用于任何场景,因此 NoInlining
选项可以防止任何可能的内联和随后的重排序,这可能会破坏函数的意图。事实上,当内联时,整个 ref
和 num = address
语句可能会被编译器优化掉。
int x = 5;
//inlined VolatileRead
ref int address = ref x; //obviously not valid without ref locals but you get the point
int num = address;
MemoryBarrier();
删除 MemoryBarrier
上方的两个语句不会影响单线程结果,因为 x = 5
满足 num = 5
(num = address
的结果)。
您可能还想知道为什么 VolatileRead
中使用 ref
。我怀疑原因是为了在进行 num = address
复制之前提供尽可能最新的值。复制本身是必要的,以防止在屏障之后但在返回之前对值进行的更改。如果另一个交错的线程持有该整数的引用,则可能发生这种情况。
最终评论
如果您对处理器和缓存的更多技术方面感兴趣,我建议您查看本文中的链接并研究相关主题,如写通/写回缓存和缓存关联性。这些内容非常有趣,但对于本文的目的来说有点过于技术化了。如果您对 CLR 更感兴趣,我还强烈推荐 Jeffrey Richter 的《CLR via C#》。我可能有点偏颇,但这本书中有一些非常有趣的主题。
下一篇文章我们将介绍更高级的同步对象,如 lock
、Semaphore
和 Mutex
,它们更容易使用和理解,以及一个称为原子性的重要概念。Thread
和 ThreadPool
也可能会首次亮相!
历史
- 1/24/17:初始发布。
- 1/24/17:更新了一些措辞和其他小的更改。