再说多线程(一)

世界是并行!

做过复杂项目的朋友一定遇到过并发的问题,无论是大项目如订票系统,还是小项目中的文件管理都会有并行需求。所以不同于上学时接触的大部分代码,实际的业务往往是为多人提供服务,必然天然的带有并发的需求。这里我先不解释并行和并发的区别,也不去讨论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真能解决我们上述所有需求吗?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值