线程同步构造
一、基元
(一) 概述
基元指的是在代码中可以使用的最简单的构造。基元是指编程中最基本、最简单的构造或元素,可以直接在代码中使用。基元通常是编程语言中的原始数据类型或基本操作符,用于构建更复杂的数据结构和算法。
举例来说,对于C#编程语言,基元可以包括整型(int)、浮点型(float)、布尔型(bool)等基本数据类型,以及算术运算符(+、-、*、/)、逻辑运算符(&&、||、!)等基本操作符。这些基元可以直接在代码中使用,用于执行基本的计算和逻辑操作。
(二) 构造
构造指的是在计算机科学和软件工程中,用于解决特定问题或实现特定功能的一组技术、方法或机制。构造可以是数据结构、算法、设计模式、库、框架等。它们帮助我们组织代码、管理数据、处理并发、实现功能等。
- 数据结构:
- 数据结构是一种组织和存储数据的方式。例如,数组、链表、栈、队列、哈希表、树和图都是常见的数据结构。
- 选择适当的数据结构对于高效地解决问题至关重要。
- 算法:
- 算法是一系列明确定义的步骤,用于解决特定问题。例如,排序算法(如快速排序、归并排序)、搜索算法(如二分查找、广度优先搜索)等。
- 算法的选择和优化对于程序的性能至关重要。
- 设计模式:
- 设计模式是一种通用的解决方案,用于解决特定类型的问题。例如,单例模式、工厂模式、观察者模式等。
- 设计模式帮助我们编写可维护、可扩展和可重用的代码。
- 库和框架:
- 库是一组函数、类或方法,用于执行特定任务。例如,标准库、第三方库等。
- 框架是一个完整的应用程序开发环境,提供了一整套功能和工具。例如,Django、React、Spring等。
- 并发和同步构造:
- 在多线程或多进程环境中,我们需要使用构造来处理并发和同步问题。例如,互锁构造、信号量、条件变量等。
总之,构造是计算机科学中的基本概念,它们帮助我们构建可靠、高效和功能强大的软件系统。
二、用户模式构造
(一) 简介
- 在用户模式下,执行的代码不能直接访问硬件或引用内存。
- 用户模式下运行的代码必须委托给系统API来访问硬件或内存。
- 由于这种隔离提供的保护,用户模式下的崩溃总是可恢复的。
- 大多数在计算机上运行的代码都在用户模式下执行。
主要技术手段包括易变构造、互锁构造、自旋锁等。
(二) 易变构造
1. 简介
易变构造(Volatile)是一种用于线程同步的关键字,它在多线程环境中确保共享资源的正确访问。
- 易变性:
- 关键字 volatile 可以应用于变量前,用于标识该变量的读写操作都是原子操作。
- 原子操作是指在执行期间不会被“拆分”,从而避免了多线程并发访问导致的数据不一致性。
- 优点:
- 易变构造能够阻止编译器对读和写进行优化,确保操作的原子性。
- 当我们需要确保变量的读写操作不被优化或重排时,可以使用 volatile 关键字。
- 示例:
- 假设我们有一个共享的停止标志变量 s_stopWorker,多个线程需要对其进行操作。
- 使用 volatile 关键字,我们可以确保每个线程对 s_stopWorker 的操作不会相互干扰。
- 示例代码:
using System; using System.Threading; class Program { private static volatile bool s_stopWorker = false; static void Main() { Console.WriteLine("Main: letting worker run for 5s"); var t = new Thread(Worker); t.Start(); Thread.Sleep(5000); // 防止优化 Volatile.Write(ref s_stopWorker, true); Console.WriteLine("Main: waiting for worker to stop."); t.Join(); } private static void Worker(object o) { int x = 0; while (s_stopWorker) x++; Console.WriteLine($"Worker: stopped when x = {x}"); } }
- 适用性:
- volatile 关键字可以应用于以下类型的字段:
- 引用类型
- 指针类型(在不安全上下文中)
- 简单类型(例如 int、float、bool)
- 具有特定基类型的枚举类型
- 已知为引用类型的泛型类型参数
- IntPtr 和 UIntPtr
- 但其他类型如 double 和 long 不能标记为 volatile,因为它们的字段读写不保证是原子的。
- 注意事项:
- 使用 volatile 关键字时,要注意性能问题。它会阻止编译器的优化,可能导致性能下降。
- 此外,还要注意避免死锁等问题。
2. 指令重排
指令重排(instruction reordering)是编译器或处理器为了优化性能而对指令执行顺序进行重新排列的过程。在计算机系统中,为了提高性能和吞吐量,编译器和处理器可能会对代码进行优化,其中之一就是对指令执行顺序进行重排。
指令重排可以分为编译器重排和处理器重排两种情况:
- 编译器重排: 编译器在生成机器代码时可能会重新排列源代码中的指令,以尽可能地利用处理器的流水线、缓存和乱序执行等特性,提高代码的执行效率。
- 处理器重排: 处理器在执行指令时也可能会对指令执行顺序进行重排,以充分利用处理器的并行执行能力和预取机制,提高指令执行效率。
然而,尽管指令重排可以提高系统的性能,但它可能会导致程序的行为不符合预期。特别是在多线程编程中,指令重排可能会导致多线程程序的并发语义被破坏,从而产生意想不到的结果或错误。
为了避免这种情况,通常会使用内存屏障(memory barrier)或者使用volatile关键字来禁止或限制编译器和处理器对指令的重排。volatile关键字可以确保对volatile变量的读写操作不会被编译器或处理器重排,从而保证多线程环境下的可见性和一致性。
(三) 互锁构造
1. 简介
互锁构造是一种用户模式线程同步构造,它在包含一个简单数据类型的变量上执行原子性的读或写操作具体来说,互锁构造主要使用了 Interlocked 类,其中包含了一些常用的方法,这些方法都是原子操作,不会产生脏读。
以下是一些常用的 Interlocked 方法:
- Increment:自增操作。
- Decrement:自减操作。
- Add:增加指定的值。
- Exchange:赋值。
- CompareExchange:比较赋值
2. 示例:
- 假设我们有一个共享的计数器变量,多个线程需要对其进行操作。
- 使用互锁构造,我们可以确保每个线程对计数器的操作不会相互干扰。
- 示例代码(C#):C#
using System; using System.Threading; class Program { private static int counter = 0; static void Main() { // 启动多个线程对计数器进行自增操作 for (int i = 0; i < 5; i++) { new Thread(IncrementCounter).Start(); } Thread.Sleep(1000); // 等待所有线程执行完毕 Console.WriteLine($"Final counter value: {counter}"); } static void IncrementCounter() { Interlocked.Increment(ref counter); } }
3. 优点:
- 原子操作确保了线程之间的操作不会相互干扰,避免了数据竞争。
- 互锁构造是一种高效的同步机制,不需要使用昂贵的内核模式锁。
4. 缺点:
- 互锁构造只适用于简单数据类型,无法处理复杂数据结构的同步。
- 如果使用不当,可能导致死锁或其他并发问题。
(四) 自旋锁
1. 简介
自旋锁是一种线程同步机制,用于保护共享资源,确保在多线程环境下对资源的访问是线程安全的。自旋锁与传统的互斥锁类似,都是用于解决多线程并发访问共享资源可能出现的竞态条件和数据不一致性问题。但它们的工作原理略有不同。
在使用互斥锁时,如果一个线程发现锁已经被其他线程占用,它就会被阻塞,直到锁被释放。而自旋锁在遇到锁被其他线程占用时,并不会立即进入阻塞状态,而是会持续尝试获取锁,直到成功为止。这个尝试获取锁的过程被称为自旋。
自旋锁适用于对共享资源的访问时间很短的情况,因为自旋锁不会引起线程的上下文切换,而且自旋操作通常比线程阻塞和唤醒的开销更小。但是,在共享资源的访问时间较长或竞争情况较激烈时,自旋锁可能会导致线程持续占用CPU资源,从而降低系统的性能。
2. 示例
在C#中,可以使用System.Threading.SpinLock类来实现自旋锁。以下是使用SpinLock类实现自旋锁的示例:
using System; using System.Threading; class Program { static SpinLock spinLock = new SpinLock(); static void Main(string[] args) { Thread t1 = new Thread(Increment); Thread t2 = new Thread(Increment); t1.Start(); t2.Start(); t1.Join(); t2.Join(); Console.WriteLine($"Final count: {count}"); } static int count = 0; static void Increment() { for (int i = 0; i < 1000000; i++) { bool lockTaken = false; try { spinLock.Enter(ref lockTaken); count++; } finally { if (lockTaken) spinLock.Exit(); } } } }
在上面的示例中,SpinLock类的Enter方法用于尝试获取自旋锁,Exit方法用于释放锁。需要注意的是,在使用自旋锁时,需要使用try...finally语句确保锁的释放,以避免锁泄露。
三、内核构造模式
- 在内核模式下,执行的代码可以完全且不受限制地访问底层硬件。
- 内核模式可以执行任何CPU指令并引用任何内存地址。
- 内核模式通常为操作系统的最低级别、最受信任的功能保留。
- 内核模式下的崩溃是灾难性的,可能导致整个计算机瘫痪
(一) Event构造
在Windows操作系统中,内核构造模式中的Event构造是一种用于线程同步的内核对象。它允许一个或多个线程等待某个事件的发生,并在事件发生时唤醒这些线程。
Event构造可以分为两种类型:自动复位事件(Auto-Reset Event)和手动复位事件(Manual-Reset Event)。
- 自动复位事件(Auto-Reset Event): 在自动复位事件中,一旦有线程等待事件,事件就会自动复位为非 signaled 状态,即未发生状态。只有等待线程中的一个会收到信号,并且事件会立即返回到非 signaled 状态。这种类型的事件适合用于同步单个操作。
- 手动复位事件(Manual-Reset Event): 在手动复位事件中,一旦有线程等待事件,事件仍然保持 signaled 状态,直到调用 Reset 方法将事件重置为非 signaled 状态。多个等待线程都会收到信号,并且事件只有在显式重置后才会返回到非 signaled 状态。这种类型的事件适合用于同步多个操作。
- 注意区分 自动复位事件一次set只能唤醒一个等待,手动复位事件一次set唤醒多个等待
在C#中,可以使用EventWaitHandle类来创建和操作事件构造。该类提供了对内核事件对象的封装,并提供了WaitOne、Set和Reset等方法来等待、设置和重置事件。
以下是一个简单的使用自动复位事件的示例:
using System; using System.Threading; class Program { static EventWaitHandle eventWaitHandle = new EventWaitHandle(false, EventResetMode.AutoReset); static void Main(string[] args) { Thread t1 = new Thread(Worker); Thread t2 = new Thread(Worker); t1.Start(); t2.Start(); // 设置事件,唤醒一个等待的线程 eventWaitHandle.Set(); t1.Join(); t2.Join(); } static void Worker() { Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} is waiting"); // 等待事件 eventWaitHandle.WaitOne(); Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} is done"); } }
在上面的示例中,两个线程(t1和t2)被创建并启动。它们都等待事件的发生。当事件被设置后,其中一个等待的线程会被唤醒,然后打印线程完成的消息。由于事件是自动复位的,所以只有其中一个线程会被唤醒。
(二) semaphore构造
内核构造模式中的 Semaphore 构造是一种用于线程同步的内核对象,它允许对资源的访问进行计数,并根据计数值来控制对资源的访问。Semaphore 可以用于控制同时访问某个资源的线程数量,以及在资源数量有限的情况下进行线程调度。
Semaphore 有一个计数器,初始值由用户指定。每当一个线程访问资源时,计数器减一;当一个线程释放资源时,计数器加一。当计数器为零时,其他线程就会被阻塞,直到有线程释放资源为止。
在 C# 中,Semaphore 是通过 System.Threading 命名空间中的 Semaphore 类来实现的。它提供了 Acquire 和 Release 方法来请求和释放资源。Semaphore 构造可以传入一个初始计数值和一个最大计数值,用于限制同时访问资源的线程数量。
以下是一个简单的示例,演示了如何使用 Semaphore 来控制对共享资源的访问:
using System; using System.Threading; class Program { static Semaphore semaphore = new Semaphore(2, 2); // 最多允许2个线程同时访问资源 static int resource = 0; static void Main(string[] args) { // 创建并启动多个线程 for (int i = 0; i < 5; i++) { Thread t = new Thread(Worker); t.Start(i); } Console.ReadLine(); } static void Worker(object id) { int threadId = (int)id; Console.WriteLine($"Thread {threadId} is trying to access resource."); // 请求资源 semaphore.WaitOne(); Console.WriteLine($"Thread {threadId} acquired the resource."); // 模拟对资源的访问 Thread.Sleep(2000); // 释放资源 semaphore.Release(); Console.WriteLine($"Thread {threadId} released the resource."); } }
在上面的示例中,Semaphore 的初始计数值为 2,最大计数值也为 2,表示最多允许两个线程同时访问资源。当有线程尝试请求资源时,Semaphore 会减少计数器,如果计数器为零,则其他线程会被阻塞直到有线程释放资源。每个线程访问资源后,都会释放资源,以便其他线程可以继续访问。
(三) mutex构造
内核构造模式中的 Mutex(互斥体)构造是一种用于线程同步的内核对象,它用于保护共享资源免受并发访问的影响。Mutex允许多个线程访问同一资源,但同一时间只允许一个线程访问该资源,其他线程必须等待互斥体被释放后才能继续执行。
Mutex具有两种状态:已锁定和未锁定。当一个线程成功获取了Mutex并且锁定了资源时,其他线程将无法获取Mutex,它们会被阻塞直到Mutex被释放。一旦拥有Mutex的线程释放了它,其他线程才有机会获取Mutex并访问共享资源。
在C#中,可以使用System.Threading命名空间中的Mutex类来创建和操作Mutex对象。Mutex类提供了WaitOne和ReleaseMutex等方法来等待和释放Mutex。
以下是一个简单的示例,演示了如何使用Mutex来保护共享资源:
using System; using System.Threading; class Program { static Mutex mutex = new Mutex(); static int sharedResource = 0; static void Main(string[] args) { // 创建并启动多个线程 for (int i = 0; i < 5; i++) { Thread t = new Thread(Worker); t.Start(i); } Console.ReadLine(); } static void Worker(object id) { int threadId = (int)id; Console.WriteLine($"Thread {threadId} is trying to access resource."); // 请求Mutex,如果被占用,则等待 mutex.WaitOne(); Console.WriteLine($"Thread {threadId} acquired the Mutex."); // 模拟对资源的访问 sharedResource++; Console.WriteLine($"Thread {threadId} increased the shared resource to {sharedResource}."); // 释放Mutex mutex.ReleaseMutex(); Console.WriteLine($"Thread {threadId} released the Mutex."); } }
在上面的示例中,每个线程在访问共享资源之前都会尝试获取Mutex。如果Mutex已经被其他线程占用,则当前线程会被阻塞直到Mutex被释放。一旦获取了Mutex,线程就可以安全地访问共享资源,并在完成后释放Mutex,以便其他线程可以继续执行。这样就保证了共享资源的安全访问。
用户VS内核
在编写代码时,应尽量使用基元用户模式构造,因为它们的速度显著快于内核模式的构造。
- 用户模式通常比内核模式的构造速度更快,因为在用户模式下,程序可以直接执行而无需操作系统的干预,而在内核模式下,需要操作系统的支持和调度,因此速度相对较慢。
- 用户模式的基元构造使用了特殊的CPU指令来协调线程,这有助于提高线程的执行效率。
- 在用户模式下,操作系统无法直接监测到一个线程是否在基元用户模式的构造上阻塞了。这是因为用户模式下的线程阻塞不会导致操作系统的线程调度器介入,因此操作系统无法感知到阻塞状态。
- 线程池不会创建新线程来替换在基元用户模式构造上阻塞的线程。这是因为线程池线程通常是基于任务队列或工作队列的,当一个线程在用户模式下被阻塞时,线程池会继续执行其他可用的任务,而不会因为阻塞而创建新线程。
因此,确实可以将基元视为一种尽量使用用户模式构造的编程原则,以提高代码的性能和效率。