深入了解C#多线程安全
作者:小六公子 发布时间:2023-08-08 19:43:32
前面两篇文章,分别简述了多线程的使用和发展历程,但是使用多线程无法避免的一个问题就是多线程安全。那什么是多线程安全?如何解决多线程安全?本文主要通过一些简单的小例子,简述多线程相关的问题,仅供学习分享使用,如有不足之处,还请指正。
什么是多线程安全?
一段程序,单线程和多线程执行结果不一致,就表示存在多线程安全问题,即多线程不安全。
多线程安全示例
1. 多线程不安全示例1
假如我们有一个需求,需要输出5个线程,且线程序号按0-4命名,我们编写代码如下:
private void btnTask1_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
for (int i = 0; i < 5; i++)
{
Task.Run(() =>
{
Console.WriteLine($"【BEGIN】**************这是第 {i} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
Thread.Sleep(2000);
Console.WriteLine($"【 END 】**************这是第 {i} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
});
}
Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
}
然后运行示例,如下所示:
通过对以上示例进行分析,得出结论如下:
1.在for循环中,启动的5个线程,线程序号都是5,并没有按照我们预期的结果【0,1,2,3,4】进行输出。
2.经过分析发现,因为for循环中,i是同一个变量,线程启动是异步进行的,存在延迟,当线程启动时,for循环已经结束,i的值为5,所以才导致线程序号和预期不一致。
为了解决上述问题,可以通过引入局部变量来解决,即每次循环声明一个变量,循环5次,存在5个变量,则相互之间不会覆盖。如下所示:
private void btnTask1_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
for (int i = 0; i < 5; i++)
{
int k = i;
Task.Run(() =>
{
Console.WriteLine($"【BEGIN】**************这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
Thread.Sleep(2000);
Console.WriteLine($"【 END 】**************这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
});
}
Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
}
运行优化后的示例,如下所示:
通过运行示例发现,局部变量可以解决相应的问题。
2. 多线程不安全示例2
假如我们有一个需求:将0到200增加到一个列表中,采用多线程来实现,如下所示:
private void btnTask2_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
List<int> list = new List<int>();
List<Task> tasks = new List<Task>();
for (int i = 0; i < 200; i++)
{
tasks.Add( Task.Run(() =>
{
list.Add(i);
}));
}
Task.WaitAll(tasks.ToArray());
string res = string.Join(",", list);
Console.WriteLine($"列表长度: {list.Count} ,列表内容:{res}");
Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
}
通过运行示例,如下所示:
通过对以上示例进行分析,得出结论如下:
1.列表的记录条数不对,会少。
2.列表的元素内容与预期的内容不一致。
针对上述问题,采用中间局部变量的方式,可以解决吗?不妨一试,修改后的 代码如下:
private void btnTask2_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
List<int> list = new List<int>();
List<Task> tasks = new List<Task>();
for (int i = 0; i < 200; i++)
{
int k = i;
tasks.Add( Task.Run(() =>
{
list.Add(k);
}));
}
Task.WaitAll(tasks.ToArray());
string res = string.Join(",", list);
Console.WriteLine($"列表长度: {list.Count} ,列表内容:{res}");
Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
}
运行优化示例,如下所示:
通过运行上述示例,得出结论如下:
1.列表长度依然不对,会小于实际单一线程的长度。注意:多线程列表长度不是一定会小于单一线程运行时列表长度,只是存在概率,即多个线程存在同时写入一个位置的概率。
2.列表内容,采用局部变量,可以解决部分问题。
由此可以得出List不是线程安全的数据类型。
加锁lock
针对多线程的不安全问题,可以通过加锁进行解决,加锁的目的:在任意时刻,加锁块都之允许一个线程访问。
加锁原理
lock实际是一个语法糖,实际效果等同于Monitor。锁定的是引用对象的一个内存地址引用。所以锁定对象不可以是值类型,也不可以是null,只能是引用类型。
lock对象的标准写法:默认情况下,锁对象是私有,静态,只读,引用对象。如下所示:
/// <summary>
/// 定义一个锁对象
/// </summary>
private static readonly object obj = new object();
然后优化程序,如下所示:
private void btnTask2_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程不安全示例btnTask1_Click**************");
List<int> list = new List<int>();
List<Task> tasks = new List<Task>();
for (int i = 0; i < 200; i++)
{
int k = i;
tasks.Add( Task.Run(() =>
{
lock (obj)
{
list.Add(k);
}
}));
}
Task.WaitAll(tasks.ToArray());
string res = string.Join(",", list);
Console.WriteLine($"列表长度: {list.Count} ,列表内容:{res}");
Console.WriteLine("【结束】**************线程不安全示例btnTask1_Click**************");
}
运行优化后的示例,如下所示:
通过对上述示例进行分析,得出结论如下:
1.加锁后,列表在多线程下也变成安全,符合预期的要求。
2.但是由于加锁的原因,同一时刻,只能由一个线程进入,其他线程就会等待,所以多线程也变成了单线程。
为何锁对象要用私有类型?
标准写法,锁对象是私有类型,目的是为了避免锁对象被其他线程使用,如果被使用,则会相互阻塞,如下所示:
假如,现在有一个锁对象,在TestLock中使用,如下所示:
public class TestLock
{
public static readonly object Obj = new object();
public void Show()
{
Console.WriteLine("【开始】**************线程示例Show**************");
for (int i = 0; i < 5; i++)
{
int k = i;
Task.Run(() =>
{
lock (Obj)
{
Console.WriteLine($"【BEGIN】*********T*****这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
Thread.Sleep(2000);
Console.WriteLine($"【 END 】*********T*****这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
}
});
}
Console.WriteLine("【结束】**************线程示例Show**************");
}
}
同时在FrmMain中使用,如下所示:
private void btnTask3_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程示例btnTask3_Click**************");
//类对象中多线程
TestLock.Show();
//主方法中多线程
for (int i = 0; i < 5; i++)
{
int k = i;
Task.Run(() =>
{
lock (TestLock.Obj)
{
Console.WriteLine($"【BEGIN】*********M*****这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
Thread.Sleep(2000);
Console.WriteLine($"【 END 】*********M*****这是第 {k} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
}
});
}
Console.WriteLine("【结束】**************线程示例btnTask3_Click**************");
}
运行上述示例,如下所示:
通过上述示例,得出结论如下:
1.T和M是成对相邻,且各代码块交互出现。
2.多个代码块,共用一把锁,是会相互阻塞的。这也是为啥不建议使用public修饰符的原因,避免被不恰当的加锁。
如果使用不同的锁对象,多个代码块之间是可以并发的【T和M是不成对,且不相邻出现,但是有同一代码块的内部顺序】,效果如下:
为什么锁对象要用static类型?
假如对象不是static类型,那么锁对象就是对象属性,不同的对象之间是相互独立的,所以不同通对象调用相同的方法,就会存在并发的问题,如下所示:
修改TestLock代码【去掉static】,如下所示:
public class TestLock
{
public readonly object Obj = new object();
public void Show(string name)
{
Console.WriteLine("【开始】**************线程示例Show--{0}**************",name);
for (int i = 0; i < 5; i++)
{
int k = i;
Task.Run(() =>
{
lock (Obj)
{
Console.WriteLine($"【BEGIN】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
Thread.Sleep(2000);
Console.WriteLine($"【 END 】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
}
});
}
Console.WriteLine("【结束】**************线程示例Show--{0}**************",name);
}
}
声明两个对象,分别调用Show方法,如下所示:
private void btnTask4_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程示例btnTask3_Click**************");
TestLock testLock1 = new TestLock();
testLock1.Show("first");
TestLock testLock2 = new TestLock();
testLock2.Show("second");
Console.WriteLine("【结束】**************线程示例btnTask3_Click**************");
}
测试示例,如下所示:
通过以上示例,得出结论如下:
非静态锁对象,只在当前对象内部进行允许同一时刻只有一个线程进入,但是多个对象之间,是相互并发,相互独立的。所以建议锁对象为static对象。
加锁锁定的是什么?
在lock模式下,锁定的是内存引用地址,而不是锁定的对象的值。假如将Form的锁对象的类型改为字符串,如下所示:
/// <summary>
/// 定义一个锁对象
/// </summary>
private static readonly string obj = "花无缺";
同时TestLock类的锁对象也改为字符串,如下所示:
public class TestLock
{
private static readonly string obj = "花无缺";
public static void Show(string name)
{
Console.WriteLine("【开始】**************线程示例Show--{0}**************",name);
for (int i = 0; i < 5; i++)
{
int k = i;
Task.Run(() =>
{
lock (obj)
{
Console.WriteLine($"【BEGIN】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
Thread.Sleep(2000);
Console.WriteLine($"【 END 】*********T*****这是第 {k}--{name} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
}
});
}
Console.WriteLine("【结束】**************线程示例Show--{0}**************",name);
}
}
运行上述示例,结果如下:
通过上述示例,得出结论如下:
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 }
那么在调用时,会相互阻塞吗?调用代码如下:
private void btnTask5_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程示例btnTask5_Click**************");
TestLock<int>.Show("AA");
TestLock<string>.Show("BB");
Console.WriteLine("【结束】**************线程示例btnTask5_Click**************");
}
运行上述示例,如下所示:
通过分析上述示例,得出结论如下所示:
1.对于泛型类,不同类型参数之间是可以相互并发的,因为泛型类针对不同类型参数会编译成不同的类,那对应的锁对象,会变成不同的引用类型。
2.如果锁对象为字符串类型,则也是会相互阻塞的,只是因为字符串是享元模式。
3.泛型T的不同,会编译成不同的副本。
递归加锁
如果在递归函数中进行加锁,会造成死锁吗?示例代码如下:
private void btnTask6_Click(object sender, EventArgs e)
{
Console.WriteLine("【开始】**************线程示例btnTask6_Click**************");
this.add(1);
Console.WriteLine("【结束】**************线程示例btnTask6_Click**************");
}
private int num = 0;
private void add(int index) {
this.num++;
Task.Run(()=> {
lock (obj)
{
Console.WriteLine($"【BEGIN】**************这是第 {num} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
Thread.Sleep(2000);
Console.WriteLine($"【 END 】**************这是第 {num} 个线程,线程ID={Thread.CurrentThread.ManagedThreadId}**************");
if (num < 5)
{
this.add(index);
}
}
});
}
运行上述示例,如下所示:
通过运行上述示例,得出结论如下:
在递归函数中进行加锁,会进行阻塞等待,但是不会造成死锁。
来源:https://www.cnblogs.com/hsiang/p/15709111.html
猜你喜欢
- 核心代码:Imei = ((TelephonyManager) getSystemService(TELEPHONY_SERVICE)).g
- 本文为大家分享了java多线程的简单实现及线程池实例,供大家参考,具体内容如下一、多线程的两种实现方式1、继承Thread类的多线程/**
- 为什么需要在应用程序中增加渠道信息?Android应用的发布需要面对各种各样的市场,我们称之为渠道。有的时候,我们需要知道应用是从哪个渠道下
- 本文讲解了如果通过springboot快速开发web服务,并读取zookeeper的过程,为后续的“在docker下部署
- 目录Future -> 代表的是未来的一个凭据AsynFuture -> Future具体实现类FutureService -&
- 本文实例为大家分享了JS Struts2多文件上传的具体代码,供大家参考,具体内容如下1、JSP页面: JS控制增加删除多个上传文件框,代码
- 本文实例讲述了Java删除二叉搜索树最大元素和最小元素的方法。分享给大家供大家参考,具体如下:在前面一篇《Java二叉搜索树遍历操作》中完成
- 目录1.概览2.自定义枚举方法3.使用 == 比较枚举类型4.在 switch 语句中使用枚举类型5.枚举类型的属性,方法和构造函数6.En
- 目录一、log4j简介1、Loggers2、Appenders3、Layouts二、配置详解1、配置根Logger:2、配置日志信息输出目的
- 一、前言(吐槽+煽情+简介) &n
- 本文将引导大家做一个音乐播放器,在做这个Android开发实例的过程中,能够帮助大家进一步熟悉和掌握学过的List
- 本文的目的是要实现左右滑动的指引效果。那么什么是指引效果呢?现在的应用为了有更好的用户体验,一般会在应用开始显示一些指引帮助页面,使用户能更
- springMVC的生命周期,听到的时候都没有反应过来,springMVC还有生命周期?现在看来生命周期就是springMVC的流程,Spr
- 日常的开发中经常会需要用到自定义View,这次刚好有个需求,需要用到带有节点的进度条。东西很简单直接继承View就行了。首先定义一些需要的属
- mysql实现配置中心本公司配置数据的管理是通过mysql进行配置管理,因为已经搭建好了,所以自己动手重新搭建一遍,熟悉整个流程。有关项目源
- 最近在机顶盒上做一个gridview,其焦点需要在item的子控件上,但gridview的焦点默认在item上,通过android:desc
- 冒泡排序冒泡排序是一种比较简单的排序算法,我们可以重复遍历要排序的序列,每次比较两个元素,如果他们顺序错误就交换位置,重复遍历到没有可以交换
- 本文实例讲述了Android开发实现拨打电话与发送信息的方法。分享给大家供大家参考,具体如下:xml布局:<LinearLayout
- 后台生成验证码工具方法 /* * 设置图片的背景色 */ public static v
- 现如今,验证码在Android的客户端还是非常普遍的.通过手机账号和验证码直接去注册应用账户的信息.很多应用都以这种方式来完成注