.NET-多线程编程-基元线程同步构造

一、导言
线程同步是多线程编程中重要的理念。在多线程编程中,将应用程序运行的结果取决于两个或多个线程哪一个优先到达特定代码块的情形,称为争用条件(Racing Conditions)
线程同步是解决争用条件的手段,即多个线程调用单个对象的属性或方法时,保证对这些调用的同步处理,避免一个线程中断另一个线程正在执行的任务,造成对象处于无效状态。而对于成员不受这类中断影响的类,我们称其是线程安全的。在.NET中,基础库FCL的静态成员是线程安全的,而对于实例成员,并不保证其线程安全。这也是开发人员需遵循的设计原则。
线程同步的方法有很多,根据操作系统,特定语言有不同的区分方法,这里我们使用.NET的线程同步概念。
通常,将线程同步分为两大类,基元线程同步构造和混合线程同步构造。基元线程同步构造还包括基元用户模式构造和基元内核模式构造,混合线程同步构造则是由不同的基元构造组合而成,在特定条件下性能较好的构造。
在具体实现上,基元用户模式构造包括:Volatile(易变构造),Interlocked(互锁构造),SpinLock(自旋锁)等;
基元内核模式构造包括:AutoResetEvent(自动重置事件),ManualResetEvent(手动重置事件),Semaphore(信号量)和Mutex(互斥体);
混合线程同步构造包括:ManualResetEventSlim(轻量级手动重置事件),SemaphoreSlim(轻量级信号量),Monitor(监控类),ReaderWriterLockSlim(轻量级读写锁)。
注:基元(Primitive)是指代码中使用的最简单的构造。

争用条件示例:

ObjC objC = new();
Task[] tasks = new Task[Environment.ProcessorCount];
for (int i = 0; i < Environment.ProcessorCount; i++)
{
	tasks[i] = new Task(objC.IncreaseValue);
	tasks[i].Start();
}

Task.WaitAll(tasks);

Console.WriteLine($"逻辑处理器数量: {Environment.ProcessorCount}");
Console.WriteLine($"      循环次数: {ObjC.LoopCount}");
Console.WriteLine($"单线程执行预期结果: {Environment.ProcessorCount * ObjC.LoopCount}");
objC.PrintValue();

public class ObjC
{
	public const int LoopCount = 10000;
	private int _value;
	public void IncreaseValue()
	{
        for (int i = 0; i < LoopCount; i++)
			_value++;
	}

	public void PrintValue()
	{
        Console.WriteLine($"多线程执行实际结果:{_value}");
    }
}

一次运行结果:

逻辑处理器数量: 12
      循环次数: 10000
单线程执行预期结果: 120000
多线程执行实际结果:85999

在上述代码中,我们实例化了类ObjC,使用多个线程调用其实例方法对其成员进行递增操作。可以看到,在未做到线程安全的情况下,多线程的运行结果不是预期的120000,这是为什么呢?
将重点关注到_value++这个语句上,如果是在单线程上下文中,在对实例字段_value进行递增时,此操作先将_value的值加载到寄存器中,然后执行递增的机器指令,使其值加1,最后将结果存储到_value中。
然而在多线程中,一个线程将_value的值加载到寄存器中。这时,如果另一个线程抢先,在第一个线程未完成剩余操作的情况下,再次将_value值加载到寄存器,并执行完接下去的步骤。可以预见,第一个线程在完成剩余的操作后,将覆盖第二个线程的结果。导致数据损坏的情况。
我们便是线程争用数据导致无效状态的情形统称为争用条件。关于线程同步的解决方案,将在后续讨论中提出。

二、用户模式构造
用户模式构造通过特殊CPU指令来协调线程,操作直接在计算机硬件中发生。因此这一过程操作系统是检测不到的。基于用户模式的构造速度较快,通常只阻塞线程相当短的时间。
常见的基元用户模式构造有:易变构造(Volatile)、互锁构造(Interlocked)以及自旋锁(SpinLock)。
2.1.易变构造
早期的软件都是汇编语言编写的,汇编语言包括一系列简单的指令,为完成复杂的计算,发明了高级语言,高级语言引入了一系列构造,如:if/else条件语句,switch/case开关语句,循环语句,类,方法,变量等,使得程序的编写简单得多。本质上,编译器仍将高级语言编写的代码转换为机器语言,使得计算机能理解你想做得事情。
在C#中,编译器将C#代码转换为中间语言(IL),在运行时,JIT将IL转换为本机CPU指令,然后由CPU执行。在这一过程中,C#编译器,JIT编译器以及CPU都能优化代码。对于单线程应用程序,我们仍能按预期执行。从多线程的角度,会遇到因编译器优化而导致非预期的问题。易变构造的引入解决了此类问题。
易变构造包括一次性读取和写入的原子操作。
C#支持关键字volatile,编译器支持语法糖,将volatile标记的字段的读取和写入,使用易变构造的读(Volatile.Read)写(Volatile.Write)方法进行替代常规的读写。
读取时,确保读取代码之后的所有代码在读取操作之后执行;
写入时,确保写入代码之前的所有代码在写入操作之前执行。

2.2.互锁构造
互锁构造提供一系列原子操作。包括添加(Add,Increment)、交换(Exchange,CompareExchange)、与(And)、或(Or)等操作。原子操作总是从操作系统层面确保线程安全的。
将前述争用条件的示例代码中,_value++语句改为Interlocked.Increment(ref _value);
如下:

	public void IncreaseValue()
	{
        for (int i = 0; i < LoopCount; i++)
			Interlocked.Increment(ref _value);
	}

运行结果如下:

逻辑处理器数量: 12
      循环次数: 10000
单线程执行预期结果: 120000
多线程执行实际结果:120000

显然,原子操作确保了递增操作是线程安全的。
可以定义以下构造,确保多线程的运行,只执行一次特定代码块(执行初始化、结果通知等)。

if (Interlocked.Exchange(ref _flag, 1) == 0)
{
  // 一次性执行的代码
}

2.3.自旋锁
自旋锁是锁的一种形式,在尝试获取锁时,线程阻塞,CPU时间在循环中(自选)等待,直到获取锁。
使用时自旋锁,在阻塞期间,CPU只能在自旋中等待,无法执行其他任务,这是一种资源的浪费。

三、内核模式构造
内核模式构造通过操作系统内核实现的函数,实现线程同步。内核模式需要从用户模式切换过来,这会招致巨大的性能损失(CPU时间)。但是,当线程需要阻塞较长时间时,通过内核模式阻塞线程,可以避免浪费时间。内核模式阻塞线程时,CPU会将时间分配给其他线程使用。相比于用户模式通过自旋无效地占用CPU时间,极大地提高了CPU使用效率。
因为内核模式构造是通过操作系统内核实现的,因此,其基元构造都是支持跨进程同步的。
常见地基元内核模式构造有:事件(EventWaitHandle,AutoResetEvent,ManualResetEvent),信号量(Semaphore)和互斥体(Mutex)。
3.1.事件
线程同步中的事件,与C#中的事件概念不同。
事件是由(操作系统)内核维护的一个bool变量,当变量为false时,表示其他线程占用了资源,这时阻塞线程,等待事件可用。当变量为true时,解除阻塞。
事件分为手动重置事件和自动重置事件。
3.2.自动重置事件
在事件解除阻塞时,唤醒一个线程。同时将状态自动重置为false,以独占资源。
分析如下代码:

JobSchedule jobSchedule = new();
jobSchedule.Run();

Console.ReadLine();

public class JobSchedule
{
	private int _count = 0;
	private AutoResetEvent _event = new AutoResetEvent(true);

	public void Run()
	{
		for (int i = 0; i < Environment.ProcessorCount; i++)
			new Thread(o => StartJobAsync()).Start();
	}

	private async void StartJobAsync()
	{
		while (true)
		{
			_event.WaitOne();

			await Task.Delay(500);

			var count = Interlocked.Increment(ref _count);
			Console.WriteLine($"已完成工作数:{count}");

			_event.Set();
		}
	}
}

运行程序可以看到,大多数线程阻塞在_event.WaitOne()等待资源的可用。因为将初始状态设置为true,第一个到达的线程可立刻获取资源,获取资源后,自动将事件状态设置为不可用,直到工作结束时,调用Set()方法重新将资源设置为可用,允许其他线程访问。之后的循环中,总是确保只有一个线程获取对资源的独占访问。

3.3.手动重置事件
在事件解除阻塞时,唤醒所有正在等待的线程。必须手动重置回false以阻塞后续其他尝试获取资源的线程。
分析如下代码:

JobSchedulePlus jobSchedule = new();
jobSchedule.Run();

Console.ReadLine();
public class JobSchedulePlus
{
	private int _count = 0;
	private ManualResetEvent _event = new ManualResetEvent(true);

	public void Run()
	{
		for (int i = 0; i < Environment.ProcessorCount; i++)
			new Thread(o => StartJobAsync()).Start();
	}

	private async void StartJobAsync()
	{
		while (true)
		{
			_event.WaitOne();
			_event.Reset();

			await Task.Delay(500);

			var count = Interlocked.Increment(ref _count);
			Console.WriteLine($"已完成工作数:{count}");

			_event.Set();
		}
	}
}

对比前一个例子可以发现,我们除了将事件从AutoResetEvent改为ManualResetEvent,只在_event.WaitOne()后面添加了一个_event.Reset()调用。
运行后可以观察到,当事件可用时,_event.WaitOne()总是能进入资源的访问,直到手动调用_event.Reset()将事件设置为不可用。手动重置事件同时允许了多个线程对资源的访问,这似乎更有助于快速并行的执行任务,充分发挥CPU算力(当然视情况选择两种事件)。

3.4.信号量
信号量是由内核维护的一个Int32变量。
当信号量为0时,在信号量上等待会阻塞,当信号量大于0时,解除阻塞。
在信号量上等待的线程解除阻塞时,内核自动将信号量计数减一,直到信号量为0时,后续线程阻塞,直到已占用的线程释放。
分析以下代码:

JobSchedule jobSchedule = new();
jobSchedule.Run();

while (true)
{
	if (int.TryParse(Console.ReadLine(), out var count))
	{
		jobSchedule.Release(count);
	}
}

public class JobSchedule
{
	private Semaphore _semaphore = new(5, 12);
	private int _count = 0;

	public void Run()
	{
		for (int i = 0; i < Environment.ProcessorCount; i++)
			new Thread(o => StartJobAsync()).Start();
	}

	private async void StartJobAsync()
	{
		while (true)
		{
			_semaphore.WaitOne();
			await Task.Delay(500);

			var count = Interlocked.Increment(ref _count);
			Console.WriteLine($"已完成工作数:{count}");
		}
	}

	public void Release(int count)
	{
		_semaphore.Release(count);
	}
}

运行程序可以发现,一开始,有5个线程可以优先解除阻塞,这取决于构造时的第一个参数;之后通过控制台每输入任意的计数n(当n大于12时,抛出异常),就有n个线程解除阻塞,执行任务。

对比三种基元内核同步构造:
多个线程在一个自动重置事件上等待时,设置事件只导致一个线程被解除阻塞;
多个线程在一个手动重置事件上等待时,设置事件会导致全部线程被解除阻塞;
多个线程在一个信号量上等待时,释放信号量会导致releaseCount个线程被解除阻塞。
可以发现,自动重置事件与信号量计数为1时的行为类似。

3.5.互斥体
互斥体是在事件和信号量的基础上构建的。
互斥体代表一个互斥的锁。工作方式和AutoResetEvent类似,他们都一次释放一个等待线程

参考:
[1]Jeffrey Richer:CLR vir C#(第四版)
[2]托管线程处理的最佳做法:https://learn.microsoft.com/zh-cn/dotnet/standard/threading/managed-threading-best-practices

posted @ 2024-05-28 17:48  iance丶  阅读(14)  评论(0)    收藏  举报