• C# 多线程入门系列(三)


    前面两篇文章,分别简述了多线程的使用和发展历程,但是使用多线程无法避免的一个问题就是多线程安全。那什么是多线程安全?如何解决多线程安全?本文主要通过一些简单的小例子,简述多线程相关的问题,仅供学习分享使用,如有不足之处,还请指正。

    什么是多线程安全?

    一段程序,单线程和多线程执行结果不一致,就表示存在多线程安全问题,即多线程不安全。

    多线程安全示例

    1. 多线程不安全示例1

    假如我们有一个需求,需要输出5个线程,且线程序号按0-4命名,我们编写代码如下:

     1 private void btnTask1_Click(object sender, EventArgs e)
     2 {
     3     Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
     4 
     5     for (int i = 0; i < 5; i++)
     6     {
     7         Task.Run(() =>
     8         {
     9             Console.WriteLine($"【BEGIN】**************这是第 {i} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    10             Thread.Sleep(2000);
    11             Console.WriteLine($"【 END 】**************这是第 {i} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    12         });
    13     }
    14 
    15     Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
    16 }

    然后运行示例,如下所示:

    通过对以上示例进行分析,得出结论如下:

    1. 在for循环中,启动的5个线程,线程序号都是5,并没有按照我们预期的结果【0,1,2,3,4】进行输出。
    2. 经过分析发现,因为for循环中,i是同一个变量,线程启动是异步进行的,存在延迟,当线程启动时,for循环已经结束,i的值为5,所以才导致线程序号和预期不一致。

    为了解决上述问题,可以通过引入局部变量来解决,即每次循环声明一个变量,循环5次,存在5个变量,则相互之间不会覆盖。如下所示:

     1 private void btnTask1_Click(object sender, EventArgs e)
     2 {
     3     Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
     4 
     5     for (int i = 0; i < 5; i++)
     6     {
     7         int k = i;
     8         Task.Run(() =>
     9         {
    10             Console.WriteLine($"【BEGIN】**************这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    11             Thread.Sleep(2000);
    12             Console.WriteLine($"【 END 】**************这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    13         });
    14     }
    15 
    16     Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
    17 }

    运行优化后的示例,如下所示:

     通过运行示例发现,局部变量可以解决相应的问题。

    2. 多线程不安全示例2

    假如我们有一个需求:将0到200增加到一个列表中,采用多线程来实现,如下所示:

     1 private void btnTask2_Click(object sender, EventArgs e)
     2 {
     3     Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
     4     List<int> list = new List<int>();
     5     List<Task> tasks = new List<Task>();
     6     for (int i = 0; i < 200; i++)
     7     {
     8         tasks.Add( Task.Run(() =>
     9         {
    10             list.Add(i);
    11         }));
    12     }
    13     Task.WaitAll(tasks.ToArray());
    14     string res = string.Join(",", list);
    15     Console.WriteLine($"列表长度: {list.Count} ,列表内容:{res}");
    16     Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
    17 }

    通过运行示例,如下所示:

     通过对以上示例进行分析,得出结论如下:

    1. 列表的记录条数不对,会少。
    2. 列表的元素内容与预期的内容不一致。

    针对上述问题,采用中间局部变量的方式,可以解决吗?不妨一试,修改后的 代码如下:

     1 private void btnTask2_Click(object sender, EventArgs e)
     2 {
     3     Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
     4     List<int> list = new List<int>();
     5     List<Task> tasks = new List<Task>();
     6     for (int i = 0; i < 200; i++)
     7     {
     8         int k = i;
     9         tasks.Add( Task.Run(() =>
    10         {
    11             list.Add(k);
    12         }));
    13     }
    14     Task.WaitAll(tasks.ToArray());
    15     string res = string.Join(",", list);
    16     Console.WriteLine($"列表长度: {list.Count} ,列表内容:{res}");
    17     Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
    18 }

    运行优化示例,如下所示:

    通过运行上述示例,得出结论如下:

    1. 列表长度依然不对,会小于实际单一线程的长度。注意:多线程列表长度不是一定会小于单一线程运行时列表长度,只是存在概率,即多个线程存在同时写入一个位置的概率。
    2. 列表内容,采用局部变量,可以解决部分问题。

    由此可以得出List不是线程安全的数据类型。

    加锁lock

    针对多线程的不安全问题,可以通过加锁进行解决,加锁的目的:在任意时刻,加锁块都之允许一个线程访问

    加锁原理

    lock实际是一个语法糖,实际效果等同于Monitor。锁定的是引用对象的一个内存地址引用。所以锁定对象不可以是值类型,也不可以是null,只能是引用类型。

    lock对象的标准写法:默认情况下,锁对象是私有,静态,只读,引用对象。如下所示:

    1 /// <summary>
    2 /// 定义一个锁对象
    3 /// </summary>
    4 private static readonly object obj = new object();

    然后优化程序,如下所示:

     1 private void btnTask2_Click(object sender, EventArgs e)
     2 {
     3     Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
     4     List<int> list = new List<int>();
     5     List<Task> tasks = new List<Task>();
     6     for (int i = 0; i < 200; i++)
     7     {
     8         int k = i;
     9         tasks.Add( Task.Run(() =>
    10         {
    11             lock (obj)
    12             {
    13                 list.Add(k);
    14             }
    15         }));
    16     }
    17     Task.WaitAll(tasks.ToArray());
    18     string res = string.Join(",", list);
    19     Console.WriteLine($"列表长度: {list.Count} ,列表内容:{res}");
    20     Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
    21 }

    运行优化后的示例,如下所示:

    通过对上述示例进行分析,得出结论如下:

    1. 加锁后,列表在多线程下也变成安全,符合预期的要求。
    2. 但是由于加锁的原因,同一时刻,只能由一个线程进入,其他线程就会等待,所以多线程也变成了单线程。

     为何锁对象要用私有类型?

    标准写法,锁对象是私有类型,目的是为了避免锁对象被其他线程使用,如果被使用,则会相互阻塞,如下所示:

    假如,现在有一个锁对象,在TestLock中使用,如下所示:

     1 public class TestLock
     2 {
     3     public static readonly object Obj = new object();
     4 
     5     public void Show()
     6     {
     7 
     8         Console.WriteLine("【开始】**************线程示例Show**************");
     9 
    10         for (int i = 0; i < 5; i++)
    11         {
    12             int k = i;
    13             Task.Run(() =>
    14             {
    15                 lock (Obj)
    16                 {
    17                     Console.WriteLine($"【BEGIN】*********T*****这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    18                     Thread.Sleep(2000);
    19                     Console.WriteLine($"【 END 】*********T*****这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    20                 }
    21             });
    22         }
    23 
    24         Console.WriteLine("【结束】**************线程示例Show**************");
    25     }
    26 }

    同时在FrmMain中使用,如下所示:

     1 private void btnTask3_Click(object sender, EventArgs e)
     2 {
     3     Console.WriteLine("【开始】**************线程示例btnTask3_Click**************");
     4     //类对象中多线程
     5     TestLock.Show();
     6     //主方法中多线程
     7     for (int i = 0; i < 5; i++)
     8     {
     9         int k = i;
    10         Task.Run(() =>
    11         {
    12             lock (TestLock.Obj)
    13             {
    14                 Console.WriteLine($"【BEGIN】*********M*****这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    15                 Thread.Sleep(2000);
    16                 Console.WriteLine($"【 END 】*********M*****这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    17             }
    18         });
    19     }
    20 
    21     Console.WriteLine("【结束】**************线程示例btnTask3_Click**************");
    22 }

    运行上述示例,如下所示:

     通过上述示例,得出结论如下:

    1. T和M是成对相邻,且各代码块交互出现。
    2. 多个代码块,共用一把锁,是会相互阻塞的。这也是为啥不建议使用public修饰符的原因,避免被不恰当的加锁。

     如果使用不同的锁对象,多个代码块之间是可以并发的【T和M是不成对,且不相邻出现,但是有同一代码块的内部顺序】,效果如下:

     为什么锁对象要用static类型?

    假如对象不是static类型,那么锁对象就是对象属性,不同的对象之间是相互独立的,所以不同通对象调用相同的方法,就会存在并发的问题,如下所示:

    修改TestLock代码【去掉static】,如下所示:

     1 public class TestLock
     2 {
     3     public  readonly object Obj = new object();
     4 
     5     public  void Show(string name)
     6     {
     7 
     8         Console.WriteLine("【开始】**************线程示例Show--{0}**************",name);
     9 
    10         for (int i = 0; i < 5; i++)
    11         {
    12             int k = i;
    13             Task.Run(() =>
    14             {
    15                 lock (Obj)
    16                 {
    17                     Console.WriteLine($"【BEGIN】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    18                     Thread.Sleep(2000);
    19                     Console.WriteLine($"【 END 】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    20                 }
    21             });
    22         }
    23 
    24         Console.WriteLine("【结束】**************线程示例Show--{0}**************",name);
    25     }
    26 }

    声明两个对象,分别调用Show方法,如下所示:

     1 private void btnTask4_Click(object sender, EventArgs e)
     2 {
     3     Console.WriteLine("【开始】**************线程示例btnTask3_Click**************");
     4     TestLock testLock1 = new TestLock();
     5     testLock1.Show("first");
     6 
     7     TestLock testLock2 = new TestLock();
     8     testLock2.Show("second");
     9     Console.WriteLine("【结束】**************线程示例btnTask3_Click**************");
    10 }

    测试示例,如下所示:

     通过以上示例,得出结论如下:

    1. 非静态锁对象,只在当前对象内部进行允许同一时刻只有一个线程进入,但是多个对象之间,是相互并发,相互独立的。所以建议锁对象为static对象。

    加锁锁定的是什么?

    在lock模式下,锁定的是内存引用地址,而不是锁定的对象的值。假如将Form的锁对象的类型改为字符串,如下所示:

    1 /// <summary>
    2 /// 定义一个锁对象
    3 /// </summary>
    4 private static readonly string obj = "花无缺";

    同时TestLock类的锁对象也改为字符串,如下所示:

     1 public class TestLock
     2 {
     3     private static  readonly string obj = "花无缺";
     4 
     5     public static  void Show(string name)
     6     {
     7 
     8         Console.WriteLine("【开始】**************线程示例Show--{0}**************",name);
     9 
    10         for (int i = 0; i < 5; i++)
    11         {
    12             int k = i;
    13             Task.Run(() =>
    14             {
    15                 lock (obj)
    16                 {
    17                     Console.WriteLine($"【BEGIN】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    18                     Thread.Sleep(2000);
    19                     Console.WriteLine($"【 END 】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    20                 }
    21             });
    22         }
    23 
    24         Console.WriteLine("【结束】**************线程示例Show--{0}**************",name);
    25     }
    26 }

    运行上述示例,结果如下:

    通过上述示例,得出结论如下:

    1. 字符串是一种特殊的锁类型,如果字符串的值一致,则认为是同一个锁对象,不同对象之间会进行阻塞。因为string类型是享元的,在内存堆里面只有一个花无缺。
    2. 如果是其他类型,则是不同的锁对象,是可以相互并发的。
    3. 说明锁定的是内存引用地址,而非锁定对象的值。

    泛型锁对象

    如果TestLock为泛型类,如下所示:

     1 public class TestLock<T>
     2 {
     3     private static  readonly object obj = new object(); 4 
     5     public static  void Show(string name)
     6     {
     7 
     8         Console.WriteLine("【开始】**************线程示例Show--{0}**************",name);
     9 
    10         for (int i = 0; i < 5; i++)
    11         {
    12             int k = i;
    13             Task.Run(() =>
    14             {
    15                 lock (obj)
    16                 {
    17                     Console.WriteLine($"【BEGIN】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    18                     Thread.Sleep(2000);
    19                     Console.WriteLine($"【 END 】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    20                 }
    21             });
    22         }
    23 
    24         Console.WriteLine("【结束】**************线程示例Show--{0}**************",name);
    25     }
    26 }

    那么在调用时,会相互阻塞吗?调用代码如下:

    1 private void btnTask5_Click(object sender, EventArgs e)
    2 {
    3     Console.WriteLine("【开始】**************线程示例btnTask5_Click**************");
    4     TestLock<int>.Show("AA");
    5     TestLock<string>.Show("BB");
    6     Console.WriteLine("【结束】**************线程示例btnTask5_Click**************");
    7 }

    运行上述示例,如下所示:

    通过分析上述示例,得出结论如下所示:

    1. 对于泛型类,不同类型参数之间是可以相互并发的,因为泛型类针对不同类型参数会编译成不同的类,那对应的锁对象,会变成不同的引用类型。
    2. 如果锁对象为字符串类型,则也是会相互阻塞的,只是因为字符串是享元模式。
    3. 泛型T的不同,会编译成不同的副本。

    递归加锁

    如果在递归函数中进行加锁,会造成死锁吗?示例代码如下:

     1 private void btnTask6_Click(object sender, EventArgs e)
     2 {
     3     Console.WriteLine("【开始】**************线程示例btnTask6_Click**************");
     4     this.add(1);
     5     Console.WriteLine("【结束】**************线程示例btnTask6_Click**************");
     6 }
     7 
     8 private int num = 0;
     9 
    10 private void add(int index) {
    11     this.num++;
    12     Task.Run(()=> {
    13         lock (obj)
    14         {
    15             Console.WriteLine($"【BEGIN】**************这是第 {num} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    16             Thread.Sleep(2000);
    17             Console.WriteLine($"【 END 】**************这是第 {num} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
    18 
    19             if (num < 5)
    20             {
    21                 this.add(index);
    22             }
    23         }
    24     });
    25 }

    运行上述示例,如下所示:

     通过运行上述示例,得出结论如下:

    1. 在递归函数中进行加锁,会进行阻塞等待,但是不会造成死锁。

    备注

    以上就是多线程安全的简单介绍,旨在抛砖引玉,大家一起学习,共同进步。

    酬乐天扬州初逢席上见赠【作者】刘禹锡 【朝代】唐

    巴山楚水凄凉地,二十三年弃置身。

    怀旧空吟闻笛赋,到乡翻似烂柯人。

    沉舟侧畔千帆过,病树前头万木春。

    今日听君歌一曲,暂凭杯酒长精神。


    作者:小六公子
    出处:http://www.cnblogs.com/hsiang/
    本文版权归作者和博客园共有,写文不易,支持原创,欢迎转载【点赞】,转载请保留此段声明,且在文章页面明显位置给出原文连接,谢谢。
    关注个人公众号,定时同步更新技术及职场文章

  • 相关阅读:
    Cocos2d-x之Vector<T>
    Cocos2d-x之Array
    Cocos2d-x之Value
    Cocos2d-x之String
    Cocos2d-x中使用的数据容器类
    Cocos2d-x之Action
    Cocos2d-x之定时器
    Cocos2d-x之MessageBox
    Cocos2d-x之Log输出机制
    Cocos2d-x之事件处理机制
  • 原文地址:https://www.cnblogs.com/hsiang/p/15709111.html
Copyright © 2020-2023  润新知