深入理解多线程并发编程


前言

多线程并发编程的领域知识体系比较庞大,底层涵盖了CPU,内存,操作系统等多方面的内容,基于我目前掌握的知识和篇幅限制,不可能深入到每一个细节。本文旨在和大家一起探讨、研究多线程并发编程中的问题及其解决方案。本文受众主要是对多线程编程技术已经有一定使用经验或正在学习多线程编程的同学。文章分三个章节给大家展开,第一节是提出多线程编程要解决哪些核心问题。第二节讲这些问题背后的本质和原理。第三节讲如何使用C#语言提供的API进行多线程并发编程。


一、多线程并发编程的核心问题

为什么多线程的程序写起来即复杂又困难?

相信有很多同学在面对多线程代码时都会望而生畏,认为多线程代码就像一头难以驯服的怪兽,就是不按你的指令行动。
下面一张图很形象的描述了多线程环境下,稍有不甚就可能翻车。图的左边是我们期望的多线程执行的场景,每只小狗都能保持在自己的环境中有序进食,实际上却是图的右侧那样,小狗们互相争抢,现场混乱不堪。
在这里插入图片描述

事实上,多线程编程也是如此,首先多线程的引入会使得程序不再保持代码逻辑上的串行性,代码执行的顺序将变成不可预测的,稍不注意就会导致程序出现各种匪夷所思的问题;其次,多线程模式也使得程序调试更加复杂和麻烦。

尽管引入多线程会使程序变得复杂,但总结下来,只需要解决好下面三个核心问题。

  • 分工问题:
    分工就是将一个比较大的任务,拆分成多个大小合适的任务,交给合适的线程去完成。
  • 同步问题:
    是指线程之间所具有的一种制约关系,一个线程的执行依赖另外一个线程的消息,当它没有得到另一个线程的信号时应等待,直到信号到达时才被唤醒。
  • 互斥问题:
    是指对于共享的进程系统资源,每个线程访问时的排他性。当有若干个线程都要使用某一个共享资源时,任何时刻最多只允许一个线程去使用,其他线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。

在并发编程领域,分工和同步强调的是执行任务的性能,而线程之间的互斥则强调的是线程执行任务的正确性,也就是线程的安全问题

二、多线程并发编程问题的源头

1.缓存导致的可见性问题

对于单核CPU来说,缓存是可见的,也就是说多个线程和CPU进行交互,这个操作每个线程之间是可见的。但是对于多核CPU来说,多个线程运行在不同的CPU,不同的CPU操作同一个内存对象,这会导致操作的不可见性,因为为了提升处理性能,每个CPU内核分别有对应有自己的高速缓存,这就意味着,多个不同的CPU上,多个线程以为自己读写相同的位置,其实看到的不是对方那个位置上的实时更新。

在这里插入图片描述

2.线程切换带来的原子性问题

原子性是一个或多个操作在CPU执行的过程中不被中断的特性。 那为什么会中断呢?原因就在于为了提高系统资源的利用率,CPU是分时间片来进行任务(线程)调度的。在高级语言编写的程序中,一个看似简单的操作可能需要多条CPU指令来完成,比如说count += 1,CPU指令至少三个,从内存中拿到count值到寄存器,在寄存器中进行+1操作,将结果写回内存,这个过程中可能会发生线程间的切换,比如下图中的执行案例,最终内存中的值是2,但这不是我们想要结果

在这里插入图片描述

3.代码指令执行的有序性问题

代码指令重排序(instruction reordering)是编译器和处理器进行代码优化的一种方式。当编译器或处理器执行指令时,它们可能会重新排序指令的执行顺序,以最大限度地利用处理器的性能。

指令重排序有两种类型:编译器重排序和处理器重排序。

  • 编译器重排序:
    代码经过了如下图的过程,编译器为了优化性能,在不改变程序语义的前提下,调整代码的执行顺序,其目的在于尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。之前写过一篇因编译期的优化导致的奇怪问题,感兴趣的同学可以点击多线程并发编程“怪”事再回顾一下。
    在这里插入图片描述

  • 处理器重排序:
    CPU 对性能的优化除了缓存之外,还有运行时指令重排,指令重排旨在最大化提高CPU执行指令的效率。现代CPU几乎都采用流水线机制加快指令的处理速度,一般来说,一条指令需要若干个CPU时钟周期处理,而通过流水线并行执行,可以在同等的时钟周期内执行若干条指令,具体做法简单地说就是把指令分为不同的执行周期,例如读取、寻址、解析、执行等步骤,并放在不同的元件中处理,同时在执行单元EU中,功能单元被分为不同的元件,例如加法元件、乘法元件、加载元件、存储元件等,可以进一步实现不同的计算并行执行。流水线架构决定了指令应该被并行执行,而不是在顺序化模型中所认为的那样。重排序有利于充分使用流水线,进而达到超标量的效果。

超标量是一种处理器架构,它能够在一个时钟周期内同时并行地执行多个指令,从而提高处理器的效率。它通过将一条指令拆分为多个子指令来实现并行执行,并利用多条指令之间的依赖关系,通过乱序执行和动态调度来优化指令的执行顺序,从而提高指令的执行效率。

缓存导致的可见性问题,线程切换带来的原子性问题,代码指令优化带来的有序性问题,本质都是为了提高程序性能,但是在提升性能的同时带来了其他问题。很多程序的并发问题是由可见性,原子性,有序性造成的,从这三个方面去考虑,可以诊断很大一部分Bug。所以没有一项技术是银蛋,在运用一项技术的时候我们一定要清楚它适用的场景是什么,带来的问题是什么,以及如何解决。

三、C#解决多线程并发编程问题

1.解决分工问题

C#语言提供了Thread,ThreadPool,Task,Parallel,async/await等类型或语法糖,来帮助我们使用多线程的方式执行程序代码。

1.1. Thread

Thread线程实例代表程序中的一个“控制点”。可以将线程想象成一名“工作者”,它独立地按照你的程序指令工作。并且针对线程整个生命周期提供了诸多状态及切换方法,使得开发人员可以对线程进行灵活的控制。了解线程状态对于编写多线程程序非常重要,因为它们有助于识别和解决线程相关的问题。
在这里插入图片描述

示例代码:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Console.WriteLine("主线程:开始执行");

        Thread thread = new Thread(new ThreadStart(DoWork));
        thread.Start();
        
        Console.WriteLine("主线程:等待子线程执行完成");
        thread.Join();
        
        Console.WriteLine("主线程:子线程执行完成,继续执行");
    }

    static void DoWork()
    {
        Console.WriteLine("子线程:开始执行");
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine("子线程:" + i);
            Thread.Sleep(1000);
        }
        Console.WriteLine("子线程:执行完成");
    }
}

1.2. ThreadPool

线程很贵,要在内存中开辟新的Stack,要增加CPU上下文切换,所以线程实例太多会对性能造成负面影响。.NET提供了线程池来缓解这种状况,并提供了ThreadPool类库,方便开发人员进行基于线程池的多线程编程。线程池是基于Thread的,应用池化思想,相当于有一个专门装线程的池,如果需要使用线程,就到池子里面去获取线程使用,使用完毕再放回池子,不需要我们写代码来管控这些线程。
在这里插入图片描述

ThreadPool的特点:
缩短应用程序的响应时间。因为在线程池中有线程处于等待分配任务状态(只要没有超过线程池的最大上限),无需创建线程。
无须手动管理和维护线程的生命周期。
线程池会根据当前系统特点(32/64,CPU核心数)对池内的线程进行优化处理。
无法对线程进行额外的控制。

示例代码:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Console.WriteLine("主线程:开始执行");

        ThreadPool.QueueUserWorkItem(DoWork);

        Console.WriteLine("主线程:等待子线程执行完成");

        Thread.Sleep(5000); // 模拟子线程执行时间
        
        Console.WriteLine("主线程:子线程执行完成,继续执行");
    }

    static void DoWork(object state)
    {
        Console.WriteLine("子线程:开始执行");

        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine("子线程:" + i);
            Thread.Sleep(1000);
        }

        Console.WriteLine("子线程:执行完成");
    }
}

1.3. Task

本质上,Task是用来管理可并行工作单元的轻量级对象。Task使用.NET的线程池来避免启动独立线程的开销,它和ThreadPool使用的是同一个线程池,并且在线程池的基础上进行了优化,提供了更多的API。Task被创建后,通过TaskScheduler执行工作项的分配。TaskScheduler会把工作项存储到两类队列中: 全局队列与本地队列。全局队列被设计为FIFO的队列。本地队列存储在线程中,被设计为LIFO。

任务调度器(task scheduler)为任务分配线程,其由抽象类TaskScheduler类代表,所有任务都会和一个任务调度器关联。.NET提供了两种具体实现:默认调度器(default scheduler)是使用 CLR 线程池工作,常见的还有LimitedConcurrencyLevelTaskScheduler,它是一个限制并发执行的任务调度器,它可以限制在特定的限制值内同时执行任务的数量。这个调度器可以帮助避免queue的任务过载,系统资源的过度占用和系统崩溃。

Task调度策略:当线程池中的线程准备好执行更多工作时,首先查看本地队列。 如果有工作项在此处等待,直接通过LIFO的模式获取执行。 如果没有,则查看其他线程的本地队列是否有可执行工作项,如果存在可执行工作项,则以FIFO的模式出队执行。如果其他线程的本地队列也没有可执行的工作项,则向全局队列以FIFO的模式获取工作项。
在这里插入图片描述
Task的特点:
支持线程的取消、完成、失败通知等交互性操作。
支持控制线程的执行顺序。
支持返回值。

示例代码:

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Console.WriteLine("主线程:开始执行");

        Task task = Task.Run(() => DoWork());

        Console.WriteLine("主线程:等待子线程执行完成");

        task.Wait();

        Console.WriteLine("主线程:子线程执行完成,继续执行");
    }

    static void DoWork()
    {
		Console.WriteLine("子线程:开始执行");

		for (int i = 0; i < 5; i++)
        {
            Console.WriteLine("子线程:" + i);
            Thread.Sleep(1000);
        }

        Console.WriteLine("子线程:执行完成");
    }
}

1.4. Parallel

一些常见的编程情形(比如顺序无关的遍历集合中的每一项进行相关处理,执行一组方法等)可通过任务提升性能,为简化编程,.NET中静态System.Threading.Tasks.Parallel类封装了这些情形,它内部任然使用Task对象。Parallel的方法都提供了一个接受一个ParallelOptions对象的重载版本,可以设置TaskScheduler, MaxDegreeOfParallelism, CancellationToken。
在这里插入图片描述

Parallel的特点:
Parallel的方法主线程也会参与计算,可以提升资源的利用率。
可以控制线程的最大并发数量。
并发执行代码一定不能依赖于特定的执行顺序。
Parallel的方法是阻塞执行的。

Parallel.For()和Parallel.ForEach()方法在每次迭代中调用相同的代码,而Parallel.Invoke()方法允许同时调用不同的方法。Parallel.ForEach()用于数据并行性,Parallel.Invoke()用于任务并行性。

示例代码:

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Console.WriteLine("主线程:开始执行");
        
        Parallel.For(0, 5, i => DoWork(i));

		Console.WriteLine("主线程:等待子线程执行完成");

        Task.WaitAll(Task.WhenAll(Parallel.Invoke(Enumerable.Range(0, 5).Select(i => Task.Run(() => DoWork(i))))));

        Console.WriteLine("主线程:子线程执行完成,继续执行");
    }

    static void DoWork(int i)
    {
        Console.WriteLine("子线程:" + i);
        Thread.Sleep(1000);
    }
}

1.5. async/await

.NET基于Task,提供了方便我们实现异步方法的关键字:async、await,编译器会对方法按照一定的规则进行转换。具体来讲,就是把await后面要执行的代码放到一个类似ContinueWith的函数中,在C#中,它是以状态机的形式表现的,每个状态都对应一部分代码,状态机有一个MoveNext()方法,MoveNext()根据不同的状态执行不同的代码,然后每个状态部分对应的代码都会设置下一个状态字段,然后把自身的MoveNext()方法放到类似ContinueWith()的回调函数中去执行,整个状态机由回调函数推动。这些事情都是编译器帮我们做的,所以我们不需要写很复杂的代码就能实现异步方法。

async/await的特点:
async修饰的方法只是表示这个方法需要编译器进行特殊处理。
并不代表方法一定是异步执行的。
适合用于实现I/O密集型的异步任务。

示例代码:

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("主线程:开始执行");

        await Task.WhenAll(Enumerable.Range(0, 5).Select(async i => DoWork(i)));

        Console.WriteLine("主线程:等待子线程执行完成");
        
        await Task.WhenAll(Enumerable.Range(0, 5).Select(async i => await DoWorkAsync(i)));
        
        Console.WriteLine("主线程:子线程执行完成,继续执行");
    }

    static async Task DoWorkAsync(int i)
    {
        Console.WriteLine("子线程:" + i);
        await Task.Delay(1000);
    }
}

2.解决同步问题

C#提供了AutoResetEvent,ManualResetEvent,ManualResetEventSlim,CountdownEvent,Barrier,Wait/Pulse等常用的信号构造来解决线程间同步问题。
在这里插入图片描述*在同一线程上发送信号并等待构造一次所需的时间(假设没有阻塞),在Intel Core i7 860上测试。

2.1. AutoResetEvent

AutoResetEvent就像一个票闸:插入一张票只允许一个人通过。该类名称中的“自动”是指在有人通过后,打开的旋转栅门自动关闭或“重置”。线程通过调用WaitOne在旋转栅门处等待或阻塞,并通过调用Set方法插入票据。如果多个线程调用WaitOne,则在旋转栅门后面会出现一个队列。一张票可以来自任何线程,换句话说,任何可以访问AutoResteEvent对象的(未阻止的)线程都可以调用该对象上的Set来释放一个被阻止的线程。
在这里插入图片描述

示例代码:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
		Console.WriteLine("主线程:开始执行");

        AutoResetEvent autoResetEvent = new AutoResetEvent(false);

        Thread thread1 = new Thread(() => DoWork(autoResetEvent));

        Thread thread2 = new Thread(() => DoWork(autoResetEvent));

        thread1.Start();

        thread2.Start();

        autoResetEvent.WaitOne(); // 等待子线程执行完成

        Console.WriteLine("主线程:子线程执行完成,继续执行");
    }

    static void DoWork(AutoResetEvent autoResetEvent)
    {
        Console.WriteLine("子线程:开始执行");

        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine("子线程:" + i);

            Thread.Sleep(1000);
        }

        autoResetEvent.Set(); // 通知主线程子线程执行完成
    }
}

2.2. ManualResetEvent

ManualResetEvent的功能与普通门类似。调用Set打开门,允许任意数量的线程调用WaitOne。调用Reset关闭门。在关闭的门上调用WaitOne的线程将阻塞;当闸门再次打开时,它们将立即释放。除了这些差异外,ManualResetEvent的功能与AutoResetEvent类似。

在多线程编程中,通常需要使用 ManualResetEvent 来实现线程之间的同步和通信。例如,在一个生产者消费者模型中,生产者线程需要向队列中添加数据,而消费者线程需要从队列中取出数据进行处理。为了保证生产者线程和消费者线程之间的同步,可以使用 ManualResetEvent 来控制数据的添加和取出。

示例代码:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Console.WriteLine("主线程:开始执行");

        ManualResetEvent manualResetEvent = new ManualResetEvent(false);

		Thread producerThread = new Thread(() =>
        {
			for (int i = 0; i < 10; i++)
            {
                Console.WriteLine("生产者线程:添加数据");
                manualResetEvent.Set(); // 将事件状态设置为已触发状态
				Thread.Sleep(1000); // 模拟数据添加过程
            }
        });

        Thread consumerThread = new Thread(() =>
        {
            for (int i = 0; i < 10; i++)
            {
                manualResetEvent.WaitOne(); // 等待事件被触发
                Console.WriteLine("消费者线程:取出数据");
                Thread.Sleep(2000); // 模拟数据处理过程
            }
        });

		producerThread.Start(); // 启动生产者线程

        consumerThread.Start(); // 启动消费者线程

        manualResetEvent.Reset(); // 将事件状态重置为未触发状态,以便下一次使用
    }
}

2.3. ManualResetEventSlim

从 Framework 4.0 开始,提供了另一个版本的ManualResetEvent,名为ManualResetEventSlim。后者为短等待时间做了优化,它提供了进行一定次数迭代自旋的能力,如果让自旋锁等待的太久(最多是几毫秒),它会和普通的信号一样出让其时间片,导致上下文切换。再被重新调度后,它会继续出让,就这样不断的“自旋出让”。这比完全使用等待句柄来进行等待的开销会少很多。它也实现了一种更有效的管理机制,允许通过CancellationToken取消Wait等待。但它不能用于跨进程的信号同步。ManualResetEventSlim不是WaitHandle的子类,但它提供一个WaitHandle的属性,会返回一个基于WaitHandle的对象(使用它的性能和一般的等待句柄相同)。

示例代码:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Console.WriteLine("主线程:开始执行");
		
        ManualResetEventSlim manualResetEvent = new ManualResetEventSlim(false);
		
        Thread producerThread = new Thread(() =>
        {
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine("生产者线程:添加数据");
                manualResetEvent.Set(); // 将事件状态设置为已触发状态
                Thread.Sleep(1000); // 模拟数据添加过程
            }
        });
		
        Thread consumerThread = new Thread(() =>
        {
            for (int i = 0; i < 10; i++)
            {
                manualResetEvent.Wait(); // 等待事件被触发
                Console.WriteLine("消费者线程:取出数据");
                Thread.Sleep(2000); // 模拟数据处理过程
            }
        });
		
        producerThread.Start(); // 启动生产者线程
		
        consumerThread.Start(); // 启动消费者线程
		
        manualResetEvent.Reset(); // 将事件状态重置为未触发状态,以便下一次使用
    }
}

2.4. CountdownEvent

AutoResetEvent和ManualResetEvent(Slim),主要用于让一个线程解锁其它一个及以上的线程,而CountdownEvent 正好相反,可以让你等待一个及以上的线程。这个类型是 Framework 4.0 加入的,并且是一个高效的纯托管实现。使用CountdownEvent时,需要指定一个计数器数值,也就是你希望等待的线程数量,调用Signal方法会将计数减 1,调用Wait会阻塞,直到计数为 0才会继续执行。

怎么理解可以让你等待一个及以上的线程
比如做一件事儿,需要3个步骤,并且三个步骤都是异步做的,等三件事儿都做完了,要进行一个汇总处理。那么我们就可以实例化一个有3个计数的CountdownEvent,汇总线程调用Wait,其他三件事儿的线程完成后分别调用Signal。
对于AutoResetEvent和ManualResetEvent,使用它们等待或者发信号需要大概 1 微秒时间(假设没有阻塞)。
ManualResetEventSlim和CountdownEvent在等待时间很短的情况下可以比上面两个快 50 倍左右。这是因为它们不依赖操作系统,并能择机使用自旋构造。然而大多数情况下,信号构造自身的开销并不会造成瓶颈,所以很少需要去考虑,除非是对性能要求极其苛刻的高度并发场景。

示例代码:

using System;
using System.Threading;

class Program
{
    static void Main(string[] args)
    {
        CountdownEvent countdownEvent = new CountdownEvent(5); // 创建一个倒计时事件,初始值为5

        Thread thread1 = new Thread(() =>
        {
            Console.WriteLine("线程1开始执行");
            Thread.Sleep(2000); // 模拟线程1执行任务的时间
            Console.WriteLine("线程1完成任务");
            countdownEvent.Signal(); // 触发倒计时事件,表示线程1已经完成任务
        });

        Thread thread2 = new Thread(() =>
        {
            Console.WriteLine("线程2开始执行");
            Thread.Sleep(3000); // 模拟线程2执行任务的时间
            Console.WriteLine("线程2完成任务");
            countdownEvent.Signal(); // 触发倒计时事件,表示线程2已经完成任务
        });

        thread1.Start(); // 启动线程1

        thread2.Start(); // 启动线程

        Console.WriteLine("主线程等待倒计时事件触发");

        countdownEvent.Wait(); // 等待倒计时事件触发,即等待两个线程都完成任务后才继续执行后续代码

        Console.WriteLine("倒计时事件已触发,所有线程已完成任务");
    }
}

2.5. Barrier

Barrier类是 Framework 4.0 加入的一个信号构造。它实现了线程执行屏障(thread execution barrier),允许多个线程在一个时间点会合。这个类非常快速和高效,它是建立在Wait / Pulse和自旋锁基础上的。
在这里插入图片描述
示例代码:

using System;
using System.Threading;

class Program
{
    static void Main(string[] args)
    {
        Barrier barrier = new Barrier(3); // 创建一个屏障,需要等待3个线程到达才能继续执行

        Thread thread1 = new Thread(() =>
        {
            Console.WriteLine("线程1开始执行");
            Thread.Sleep(2000); // 模拟线程1执行任务的时间
            Console.WriteLine("线程1完成任务");
            barrier.SignalAndWait(); // 调用信号方法并等待所有线程到达屏障后才继续执行后续代码
        });

        Thread thread2 = new Thread(() =>
        {
            Console.WriteLine("线程2开始执行");
            Thread.Sleep(3000); // 模拟线程2执行任务的时间
            Console.WriteLine("线程2完成任务");
            barrier.SignalAndWait(); // 调用信号方法并等待所有线程到达屏障后才继续执行后续代码
        });

        Thread thread3 = new Thread(() =>
        {
            Console.WriteLine("线程3开始执行");
            Thread.Sleep(1000); // 模拟线程3执行任务的时间
            Console.WriteLine("线程3完成任务");
            barrier.SignalAndWait(); // 调用信号方法并等待所有线程到达屏障后才继续执行后续代码
        });

        thread1.Start(); // 启动线程1

        thread2.Start(); // 启动线程2

        thread3.Start(); // 启动线程3
    }
}

2.6. Wait/Pulse

由Monitor类通过两个静态方法Wait和Pulse(以及PulseAll)提供了一个更强大的信号构造,可以是使用自定义的标识和字段自行实现信号同步逻辑。仅仅使用这些方法和lock,就可以实现AutoResetEvent、ManualResetEvent,还有WaitHandle的静态方法WaitAll和WaitAny的功能。此外,Wait和Pulse也可以用于所有等待句柄都不适用的情况。

示例代码:

class SimpleWaitPulse
{	
	static readonly object _locker = new object();
 	static bool _go;
 
  	static void Main()
  	{                               
    	new Thread(Work).Start();    
    	
    	Console.ReadLine();     
    	       
    	lock (_locker)                 
    	{                              
      		_go = true;
      		Monitor.Pulse(_locker);
    	}
  	}
  
  	static void Work()
  	{
    	lock(_locker)
    	{
      		while(!_go)
      		{   
      			Monitor.Wait(_locker);	//在等待期间锁被释放了
      		}
      	}
      	
      	Console.WriteLine("唤醒!!!");
    }
 }

3.解决线程互斥问题

C#提供了lock(Monitor),Mutex,ReaderWriterLockSlim,Semaphore,SemaphoreSlim等常用的锁构造来解决线程互斥问题。
在这里插入图片描述
*在同一线程上锁定和解锁构造一次所需的时间(假设没有阻塞),在Intel Core i7 860上测试。

3.1.lock(Monitor)

C# 的lock语句是一个语法糖,它其实就是使用了try / finally来调用Monitor.Enter与Monitor.Exit方法。Monitor是最基本、最简单的锁构造。它可以用于同步多个线程对共享资源的访问。使用Monitor时需要注意,如果一个线程已经持有了某个对象的Monitor,那么其他线程必须等待该线程释放Monitor后才能获取Monitor并访问共享资源,也就是说在同一时间只有一个线程可以锁定同步对象,并且其它竞争锁的线程会被阻塞,直到锁被释放。如果有多个线程在竞争锁,它们会在一个“就绪队列(ready queue)”中排队,并且遵循先到先得的规则(需要说明的是,Windows 系统和 CLR 的差别可能导致这个队列在有时会不遵循这个规则)

CRL初始化时在堆中分配一个同步块数组,每当一个对象在堆中创建的时候,都有两个额外的开销字段与它关联。第一个是“类型对象指针”,值为类型的“类型对象”的内存地址。第二个是“同步块索引”,值为同步块数据组中的一个整数索引。一个对象在构造时,它的同步块索引初始化为-1,表明不引用任何同步块。然后,调用Monitor.Enter时,CLR在同步块数组中找到一个空白同步块,并设置对象的同步块索引,让它引用该同步块。调用Exit时,会检查是否有其他任何线程正在等待使用对象的同步块。如果没有线程在等待它,同步块就自由了,会将对象的同步块索引设回-1,自由的同步块将来可以和另一个对象关联。

在这里插入图片描述

使用lock的注意事项:

  • 避免锁定可以被公共访问的对象 lock(this)、lock(typeof(ClassName)) 、lock(public static variable) 、lock(public const variable),都存在可能被其他代码锁定的情况,这样会阻塞你自己的代码。

  • 禁止锁定字符串,在编译阶段如果两个变量的字符串内容相同的话,CLR会将字符串放在(Intern Pool)驻留池(暂存池)中,以此来保证相同内容的字符串引用的地址是相同的。所以如果有两个地方都在使用lock(“myLock”)的话,它们实际锁住的是同一个对象。

  • 禁止锁定值类型的对象 Monitor的方法参数为object类型,所以传递值类型会导致值类型被装箱,造成线程在已装箱对象上获取锁。每次调用Moitor.Enter都会在一个完全不同的对象上获取锁,所以完全无法实现线程同步。

  • 避免死锁,如果两个线程中的每个线程都尝试锁定另一个线程已锁定的资源,则会发生死锁。我们应该保证每块代码锁定对象的顺序一致。尽量避免锁定可被公共访问的对象,因为私有对象只有我们自己用,我们可以保证锁的正确使用。我们还可以利用Monitor.TryEnter来检测死锁,该方法支持设置获取锁的超时时间,比如,Monitor.TryEnter(lockObject,300),如果在300毫秒内没有获取锁,该方法返回false。

示例代码:

using System;
using System.Threading;

class Program
{
    static void Main(string[] args)
    {
        int counter = 0;

        object lockObject = new object();

		Thread thread1 = new Thread(() =>
        {
            for (int i = 0; i < 100000; i++)
            {
				lock (lockObject)
                {
                    counter++;
                }
			}
        });

        Thread thread2 = new Thread(() =>
        {
            for (int i = 0; i < 100000; i++)
			{
                lock (lockObject)
                {
                    counter--;
                }
            }
        });

        thread1.Start();

        thread2.Start();

        thread1.Join();

        thread2.Join();

		Console.WriteLine("Counter value: " + counter); // Output: Counter value: 0
    }
}

3.2.Mutex

Mutex类似于 C# 的lock,不同在于它是可以跨越多个进程工作。使用Mutex类时,可以调用WaitOne方法来加锁,调用ReleaseMutex方法来解锁。关闭或销毁Mutex会自动释放锁。与lock语句一样,Mutex只能被获得该锁的线程释放。

Mutex类似于 C# 的lock,但不是说它的实现原理和lock类似,Mutex 继承自WaitHandle,是通过内核对象句柄实现的。
跨进程Mutex的一种常见的应用就是确保只运行一个程序实例。

示例代码:

using System;
using System.Threading;

class Program
{
    static Mutex mutex = new Mutex(false, "MyAppName"); // 创建一个名为"MyAppName"的Mutex对象

    static void Main(string[] args)
    {
        Console.WriteLine("按任意键开始程序...");

        Console.ReadKey();

        try
        {
            mutex.WaitOne(); // 等待Mutex锁被释放

            Console.WriteLine("程序正在运行...");

            // 在这里编写需要在程序运行时执行的代码
        }
        catch (AbandonedMutexException)
        {
            Console.WriteLine("程序的另一个实例已经在运行中");
		}
    }
}

3.3.ReaderWriterLockSlim

通常,一个类型的实例对于并发读操作是线程安全的,但对并发的更新操作却不是(并发读然后更新也不是)。类似文件这种类型的资源,尽管可以简单的对所有访问都使用排它锁来确保这种类型的实例是线程安全的,但对于读多写少的情况,它就会过度限制并发能力。在这种情况下,ReaderWriterLockSlim类被设计用来提高并发能力的锁。ReaderWriterLockSlim分为读锁和写锁,写锁完全的排它,读锁可以与其它的读锁相容。所以,一个线程持有写锁会阻塞其它想要获取读锁或写锁的线程(反之亦然)。而如果没有线程持有写锁,任意数量的线程可以同时获取读锁。
在这里插入图片描述

示例代码:

using System;
using System.Threading;
using System.Threading.Tasks;

class Counter
{
    private int _count;

    private ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();

    /// <summary>
    /// 获取当前计数器的值。
    /// </summary>
    public int Count
    {
        get { return _count; }

        set
        {
            if (value < 0) throw new ArgumentOutOfRangeException("value");

            _lock.EnterWriteLock(); // 获取写锁

            try
            {
                _count = value;
            }

            finally
            {
                _lock.ExitWriteLock(); // 释放写锁
            }
        }
    }

    /// <summary>
    /// 在异步模式下向计数器中添加一个值。
    /// </summary>
    /// <param name="count">要添加的值。</param>
    /// <returns>添加后的计数器值。</returns>
    public async Task<int> AddAsync(int count)
    {
        if (count < 0) throw new ArgumentOutOfRangeException("count");

        _lock.EnterWriteLock(); // 获取写锁

        try
        {
            int currentCount = _count;

            _count += count;

            return currentCount + count;
        }
        finally
        {
            _lock.ExitWriteLock(); // 释放写锁
        }
    }
}

class Program
{
    static void Main(string[] args)
    {
        var counter = new Counter();

        Console.WriteLine("Initial count: " + counter.Count); 

        var task1 = Task.Run(() => counter.AddAsync(5)); 

        var task2 = Task.Run(() => counter.AddAsync(-3)); 

        task1.Wait(); 

        Console.WriteLine("Final count: " + counter.Count);
    }
}

3.4.Semaphore

Semaphore类似于一个停车场:它具有一定的容量,并且有门卫把守。一旦没有空位,就不允许其他车进入,这些车将在外面排队。当有一辆车离开时,排在最前头的车便可以进入。这种构造最少需要两个参数:停车场当前的空位数以及停车场的总容量。

容量为 1 的Semaphore与Mutex和lock类似,所不同的是Semaphore没有“所有者”,它是线程无关的。任何线程都可以在调用Semaphore上的Release方法,而对于Mutex和lock,只有获得锁的线程才可以释放。Semaphore常用于解决有限并发的需求,它可以阻止过多的线程同时执行特定的代码段。
在这里插入图片描述

示例代码:

using System;
using System.Threading;

class Program
{
    static void Main(string[] args)
    {
        int maxNumberOfThreads = 5; // 最大线程数

        Semaphore semaphore = new Semaphore(maxNumberOfThreads, maxNumberOfThreads, "MySemaphore"); // 创建一个Semaphore对象,用于控制同时访问共享资源的线程数

        for (int i = 0; i < 10; i++) // 循环10次,模拟多个线程同时访问共享资源的情况
        {
            Console.WriteLine("线程 " + i + " 正在等待获取信号量..."); 

            semaphore.WaitOne(); // 等待获取信号量,如果信号量计数器大于0,则会立即返回true并将信号量计数器减1,否则会一直等待直到有其他线程释放了信号量

            Console.WriteLine("线程 " + i + " 已经获取了信号量并开始执行任务的信息..."); 

            Thread.Sleep(200); // 让当前线程休眠200毫秒,模拟执行任务的时间

            Console.WriteLine("线程 " + i + " 正在释放信号量的信息..."); 

            semaphore.Release(); // 释放信号量,将信号量计数器加1,允许其他等待的线程获取信号量
        }
    }
}

3.5.SemaphoreSlim

SemaphoreSlim是 Framework 4.0 加入的轻量级的信号量,功能与Semaphore相似,不同之处是它具有更低的延迟。它也实现了一种更有效的管理机制,支持在等待时指定取消标记 (cancellation token),但它不能跨进程使用。SemaphoreSlim产生的开销约是Semaphore的四分之一。

3.6.Interlocked

Interlocked为多个线程共享的变量提供原子操作。它的方法通常产生比较小的开销,大概是无竞争锁的一半。此外,使用它不会导致阻塞,所以不会带来上下文切换的开销。通常使用Interlocked替换lock,用于实现整数的加减运算或交换两个变量值的场景。

示例代码:

using System;
using System.Threading;

class Program
{
  static long _sum;

  static void Main()
  {
    // 简单的自增/自减操作:
    Interlocked.Increment (ref _sum); 
    Interlocked.Decrement (ref _sum);
    // 加/减一个值:
    Interlocked.Add (ref _sum, 3);
    // 读取64位字段:
    Console.WriteLine (Interlocked.Read (ref _sum));
    // 打印 3,并且将 _sum 更新为 10 
    Console.WriteLine (Interlocked.Exchange (ref _sum, 10));
    // 仅当字段的当前值匹配特定的值(10)时才更新它:
    Console.WriteLine (Interlocked.CompareExchange (ref _sum, 123, 10);
  }
}

总结

多线程并发编程旨在最大限度的利用计算机的资源,提高程序执行的性能,这需要线程之间的分工和同步来实现,在保证性能的同时,又需要保证线程的安全。C#提供了完善的API帮助开发者很容易的实现多线程并发编程,我们从线程/线程池讲到基于任务的编程模型,再讲到基元线程同步构造以及混合线程同步构造,总结了不同的API适用的并发场景及特点,为大家提供指导建议。在解决互联网应用“三高”问题中,多线程技术起到了非常关键的作用,它已经是我们开发人员不可或缺的技术能力,希望本次分享能给大家学习多线程技术带来帮助。

  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值