7. 线程问题

用多个线程编程并不容易。在启动访问相同数据的多个线程时,会间歇性地遇到难以发现的问题。如果使用任务、并行LINQ或Parallel类,也会遇到这些问题。为了避免这些问题,必须特别注意同步问题和多个线程可能发生的其他问题。下面探讨与线程相关的问题:争用条件和死锁。

ThreadingIssues示例的代码使用了如下名称空间:

System.Diagnostics

System.Threading

System.Threading.Tasks

static System.Console

可以使用命令行参数启动ThreadingIssues示例应用程序,来模拟争用条件或死锁。

1. 争用条件

如果两个或多个线程访问相同的对象,并且对共享状态的访问没有同步,就会出现争用条件。为了说明争用条件,下面的例子定义一个StateObject类,它包含一个int字段和一个ChangeState()方法。在ChangeState()方法的实现代码中,验证状态变量是否包含5.如果它包含,就递增其值。下一条语句是Trace.Fail,它立刻验证state现在是否包含6。

在给包含5的变量递增了1后,可能认为该变量的值就是6.但事实不一定是这样。例如,如果一个线程刚刚执行完if(_state == 5)语句,它就被其他线程抢占,调度器运行另一个线程。第二个线程现在进入if体,因为_state的值仍是5,所以将它递增到6。第一个线程现在再次被调度,在下一条语句中,_state递增到7.这时,这时就发生了争用条件,并显示断言消息。

    class StateObject
    {
        private int _state = 5;
        public void ChangeState(int loop)
        {
            if (_state == 5)
            {
                _state++;
                if (_state != 6)
                {
                    WriteLine($"Race condition occurred after {loop} loops");
                    Trace.Fail("race condition");
                }
            }
            _state = 5;
        }
    }

下面通过给任务定义一个方法来验证这一点。SampleTask类的RaceCondition()方法将一个StateObject类作为其参数。在一个无限while循环中,调用ChangeState()方法。变量i仅用于显示断言消息中的循环次数。

    class SampleTask
    {
        public void RaceContition(object o)
        {
            Trace.Assert(o is StateObject,"o must be of type StateObject");
            StateObject stateObject = o as StateObject;
            int i = 0;
            while (true)
            {
                stateObject.ChangeState(i++);
            }
        }
    }

在程序的Main()方法中,新建了一个StateObject对象,它由所有任务共享。通过使用传递给Task的Run方法的lambda表达式调用RaceCondition方法来创建Task对象。然后,主线程等待用户输入。但是,因为可能出现争用,所有程序很有可能在读取用户输入前就挂起:

        static void RaceContitions()
        {
            var stateObject = new StateObject();
            for (int i = 0; i < 2; i++)
            {
                Task.Run(()=>new SampleTask().RaceContition(stateObject));
            }
        }

运行结果:

Race condition occurred after 1722 loops
Race condition occurred after 37252 loops
Race condition occurred after 1895580 loops
...

启动程序,就会出现争用条件。多久以后出现第一个争用条件要取决于系统以及将程序构建为发布版本还是调试版本。如果构建为发布版本,该问题的出现次数就会比较多,因为代码被优化了。如果系统中有多个CPU或使用双核/四核CPU,其中多个线程可以同时运行,则该问题也会比单核CPU的出现次数多。在单核CPU中,因为线程调度是抢占式的,也会出现该问题,只是没有那么频繁。

在我的系统上运行程序时,显示在1722个循环后出现错误;在另一次运行程序时,显示在5247个循环后出现错误。多次启动应用程序,总是会得到不同的结果。

要避免该问题,可以锁定共享的对象。这可以在线程中完成:用下面的lock语句锁定在线程中共享的stateObject变量。只有一个线程能在锁定块中处理共享的stateObject对象。由于这个对象在所有的线程之间共享,因此,如果一个线程锁定了stateObject,另一个线程就必须等待该锁定的解除。一旦接受锁定,线程就拥有该锁定,直到该锁定块的末尾才能解除锁定。如果改变stateObject变量引用的对象的每个线程使用一个锁定,就不会出现争用条件。

    class SampleTask
    {
        public void RaceContition(object o)
        {
            Trace.Assert(o is StateObject,"o must be of type StateObject");
            StateObject stateObject = o as StateObject;
            int i = 0;
            while (true)
            {
                lock (stateObject) //no race condition with this lock
                {
                    stateObject.ChangeState(i++);
                }                
            }
        }
    }

在使用共享对象时,除了进行锁定之外,还可以将共享对象设置为线程安全的对象。在下面的代码中,ChangeState()方法包含一条lock语句。由于不能锁定_state变量本身(只有引用类型才能用于锁定),因此定义一个object类型的变量sync,将它用于lock语句。如果每次_state的值更改时,都使用同一个同步对象来锁定,就不会出现争用条件。

    class StateObject
    {
        private int _state = 5;
        private object sync = new object();
        public void ChangeState(int loop)
        {
            lock (sync)
            {
                if (_state == 5)
                {
                    _state++;
                    if (_state != 6)
                    {
                        WriteLine($"Race condition occurred after {loop} loops");
                        Trace.Fail("race condition");
                    }
                    WriteLine($"_state value: {_state}");
                }
                _state = 5;
            }           
        }
    }

2. 死锁

过多的锁定也会有麻烦。在死锁中,至少有两个线程被挂起,并等待对方解除锁定。由于两个线程都在等待对方,就出现了死锁,线程将无限等待下去。

为了说明死锁,下面实例化StateObject类型的两个对象,并把它们传递给SampleTask类的构造函数。创建两个任务,其中一个任务运行Deadlock1()方法,另一个任务运行Deadlock2()方法:

            var state1 = new StateObject();
            var state2 = new StateObject();
            new Task(new SampleTask(state1, state2).Deadlock1).Start();
            new Task(new SampleTask(state1, state2).Deadlock2).Start();

Deadlock1()和Deadlock2()方法现在改变两个对象s1和s2的状态,所以生成了两个锁。Deadlock1()方法先锁定s1,接着锁定s2。Deadlock2()方法先锁定s2,再锁定s1。现在,有可能Deadlock1()方法中s1的锁定会被解除。接着,出现一次线程切换,Deadlock2()方法开始运行,并锁定s2。第二个线程现在等待s1锁定的解除。因为它需要等待,所以线程调度器再次调度第一个线程,但第一个线程在等待s2锁定的解除。这两个线程现在都在等待,只要锁定块没有结束,就不会解除锁定。这是一个典型的死锁。

        public void Deadlock1()
        {
            int i = 0;
            while (true)
            {
                lock (_s1)
                {
                    lock (_s2)
                    {
                        _s1.ChangeState(i);
                        _s2.ChangeState(i++);
                        Console.WriteLine($"still running, {i}");
                    }
                }
            }
        }
        public void Deadlock2()
        {
            int i = 0;
            while (true)
            {
                lock (_s2)
                {
                    lock (_s1)
                    {
                        _s1.ChangeState(i);
                        _s2.ChangeState(i++);
                        Console.WriteLine($"still running, {i}");
                    }
                }
            }
        }

运行结果:

_state value: 6
_state value: 6
still running, 1
_state value: 6
_state value: 6
still running, 2
...
_state value: 6
_state value: 6
still running, 53

结果是,程序运行了许多次循环,不久就没有响应了。"still running"的消息仅写入控制台中53次(对于当前设备,当前运行结果而言)。同样,死锁问题的发生频率也取决于系统配置,每次运行的结果都不同。

死锁问题并不总是像这样那么明显。一个线程锁定了s1,接着锁定s2;另一个线程锁定了s2,接着锁定s1。在本例中只需要改变锁定顺序,这两个线程就会以相同的顺序进行锁定。但是,在较大的应用程序中,锁定可能隐藏在方法的深处。为了避免这个问题,可以在应用程序的体系架构中,从一开始就设计好锁定顺序,也可以为锁定定义超时时间。如何定义超时时间详见下一节内容。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值