世界是并行!
做过复杂项目的朋友一定遇到过并发的问题,无论是大项目如订票系统,还是小项目中的文件管理都会有并行需求。所以不同于上学时接触的大部分代码,实际的业务往往是为多人提供服务,必然天然的带有并发的需求。这里我先不解释并行和并发的区别,也不去讨论cpu和操作系统的低层是如何做到并发的,让我们从最简单的串行说起。
1.串行世界
看下面一个简单的例子:
public static void Print(string name)
{
Console.Write("[Today is raining day! ");
Thread.Sleep(10);
Console.WriteLine($"--- What do you do? : {(name as string)}]");
}
一个简单的打印函数,只不过,在两句话之间,添加了一个小停顿。假设有三个人访问你家,你依次跟三个人进行上面的对话,那么你应该这样写:
public static void Talk()
{
Print("王总");
Print("张总");
Print("刘总");
}
那运行结果自然是:
[Today is raining day! --- What do you do? : 王总]
[Today is raining day! --- What do you do? : 张总]
[Today is raining day! --- What do you do? : 刘总]
假设是公司开会,来了很多客人,如果只安排你一个接待员,那肯定要接待很久,造成不良影响,所以你打算再找2个人帮你,这样一来接待效率变为之前的三倍,那程序要怎么处理呢?
2. 并发的问题
假设一个程序是一个线程,那三个接待员就是三个线程,C#中有一个线程类Thread就是来构造多线程的,基本用法可以参考MSDN。所以我们用Thread来模拟三个接待员:
首先把打印函数调整一下,以便作为参数传入线程中:
public static void Print(object? name)
{
Console.Write("[Today is raining day! ");
Thread.Sleep(1);
Console.WriteLine($"--- What do you do? : {(name as string)}]");
}
然后构造三个线程执行:
public static void Run()
{
Thread t1 = new(Print) { Name = "t1" };
Thread t2 = new(Print) { Name = "t2" };
Thread t3 = new(Print) { Name = "t3" };
t1.Start("张总");
t2.Start("王总");
t3.Start("刘总");
Console.ReadLine();
}
运行结果:
[Today is raining day! [Today is raining day! [Today is raining day! --- What do you do? : 王总]
--- What do you do? : 张总]
--- What do you do? : 刘总]
对话发生了混乱,显然可能是因为Sleep函数导致每个线程的第二段打印都滞后了,我们去掉Thread.Sleep().如果再次运行:
[Today is raining day! --- What do you do? : 张总]
[Today is raining day! [Today is raining day! --- What do you do? : 刘总]
--- What do you do? : 王总]
重复运行:
[Today is raining day! [Today is raining day! --- What do you do? : 张总]
--- What do you do? : 王总]
[Today is raining day! --- What do you do? : 刘总]
发现输出结果是无法预料的,这就像100个人同时抢10张火车票,假设同时开抢,后台同时收到100个订单,结果也可能是无法预知的。在我们刚才的例子,虽然打印混乱影响不大,但是在某些场景,这是很致命的,比如有名次的抽奖,比如抢演唱会门票。
所以,并行世界会衍生出很多串行程序中没有的问题,熟悉数据库的朋友都知道,数据库有各种锁来保证数据一致性,所以并发程序应该也是如此。纵观并发程序的各种设计,无非是要在下面两点下功夫:
原子性:线程同步要支持原子性,也就是保证多个线程运行时不能让他们同时都能访问公共数据,以免造成数据的不一致性,程序的关键代码被原子性的执行(也就是有且只有一个线程在运行)。比如刚才讲得订票案例就是如此(不能让一张票分给了2个人)。
顺序性:我们通常希望两个或更多线程以特定顺序执行任务,或者我们希望将对共享资源的访问限制为仅特定数量的线程。通常,我们对这一切没有太多控制,这是竞争条件的原因之一。线程同步提供对排序的支持,以便您可以控制线程以根据您的要求执行任务。
当进程或线程想要访问对象时,它会请求锁定该对象。有两种类型的锁决定了对共享资源的访问——独占锁和非独占锁。
独占锁:独占锁确保在任何给定时间点只有一个线程可以访问或进入临界区。在C#中,我们可以使用lock关键字、Monitor类、Mutex类、SpinLock类来实现Exclusive Lock。
非排他锁: 非排他锁提供对共享资源的只读访问并限制并发,即限制对共享资源的并发访问数。在 C# 中,我们可以使用 Semaphore、SemaphoreSlim 和 ReaderWriterLockSlim 类来实现非排他锁。
3.加锁
3.1 C#中的lock语句是什么?
按照微软的说法,lock语句获取给定对象的互斥锁,执行一个语句块,然后释放锁。持有锁时,持有锁的线程可以再次获取和释放锁。任何其他线程都被阻止获取锁并等待直到锁被释放。
注意:当你想同步线程访问一个共享资源时,你应该将共享资源锁定在一个专用的对象实例上(例如,private readonly object _lockObject = new object();或private static readonly object _lockObject = new object() ; ). 避免对不同的共享资源使用相同的锁对象实例,因为这可能会导致死锁。
3.2 C# 中的 lock 语句在内部是如何工作的?
当我们编译代码时,C# 中的 lock 语句在内部转换为 try-finally 块。锁定语句的编译代码如下所示。可以看到,它在内部使用了Monitor类的Enter和Exit方法。在后面文章中,我们将详细讨论Monitor 类的 Enter 和 Exit 方法,现在为了理解,我们可以说的是,它通过调用 Monitor 类的 Enter 方法在 try 块中获取独占锁并在 finally 块中释放获得的独占锁。
3.3 自增实例
我们看一个例子,假设三个线程给一个int变量增加:
internal class Increment
{
static int Count = 0;
private static readonly object Lock = new object();
public static void Add()
{
Thread t1 = new Thread(IncrementCount);
Thread t2 = new Thread(IncrementCount);
Thread t3 = new Thread(IncrementCount);
t1.Start();
t2.Start();
t3.Start();
Console.WriteLine($"Count: {Count}");
Console.Read();
}
static void IncrementCount()
{
for(int i = 0; i < 100000; i++)
{
Count++;
}
}
}
理想值应该是300000,但是每次运行都到不了300000,说明打印结果时,三个线程还没运行结束,所以我们需要保证三个线程先运行结束,Thread.Join函数就是强制线程结束后再运行后面的代码。所以Add函数改为:
public static void Add()
{
Thread t1 = new Thread(IncrementCount);
Thread t2 = new Thread(IncrementCount);
Thread t3 = new Thread(IncrementCount);
t1.Start();
t2.Start();
t3.Start();
//Wait for all three threads to complete their execution
t1.Join();
t2.Join();
t3.Join();
Console.WriteLine($"Count: {Count}");
Console.Read();
}
再次运行,你会发现还是无法变成稳定的值,其实经过前面的介绍,应该很容易分析,由于没有加锁,那么当Count=99时,可能同时被2个线程获取,理论上2个线程各加了1,应该为101,但实际上,在做加法后,给Count复值写入时,均写的是100,这样无疑就漏了一次。可以想象,假设我开辟100个线程,那这种情况就会出现相当多次,最终结果必然是小于串行运行的结果。
所以我们给InCrementLock函数加锁:
这里我们给关键代码加锁Lock,让大家看看效果如何:
public static void Print(object? name)
{
lock(locker) //如果不加锁,则显示不会一致
{
Console.Write("[Today is raining day! ");
Thread.Sleep(10);
Console.WriteLine($"--- What do you do? : {(name as string)}]");
}
}
其它部分的代码不变,运行结果为:
[Today is raining day! --- What do you do? : 张总]
[Today is raining day! --- What do you do? : 王总]
[Today is raining day! --- What do you do? : 刘总]
现在看起来整洁多了,但是一个lock真能解决我们上述所有需求吗?