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

简介: C# 多线程入门系列(三)

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

什么是多线程安全?

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

多线程安全示例

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**************");
}

测试示例,如下所示:

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

  1. 非静态锁对象,只在当前对象内部进行允许同一时刻只有一个线程进入,但是多个对象之间,是相互并发,相互独立的。所以建议锁对象为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为泛型类,如下所示:

public class TestLock<T>
{
    private static  readonly object obj = new object(); 4 
    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);
     }
}

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

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);
            }
        }
    });
}

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

2aa7f1235447a0ab6cc451191041bb50.png

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

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

备注

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

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

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

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

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

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


相关文章
|
1月前
|
开发框架 .NET API
RESTful API 设计与实现:C# 开发者的一分钟入门
【10月更文挑战第5天】本文从零开始,介绍了如何使用 C# 和 ASP.NET Core 设计并实现一个简单的 RESTful API。首先解释了 RESTful API 的概念及其核心原则,然后详细说明了设计 RESTful API 的关键步骤,包括资源识别、URI 设计、HTTP 方法选择、状态码使用和错误处理。最后,通过一个用户管理 API 的示例,演示了如何创建项目、定义模型、实现控制器及运行测试,帮助读者掌握 RESTful API 的开发技巧。
57 7
|
26天前
|
安全 数据处理 开发者
Python中的多线程编程:从入门到精通
本文将深入探讨Python中的多线程编程,包括其基本原理、应用场景、实现方法以及常见问题和解决方案。通过本文的学习,读者将对Python多线程编程有一个全面的认识,能够在实际项目中灵活运用。
|
1月前
|
C#
C#入门
C#入门
26 0
|
7天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
16天前
|
安全 Java 调度
Java中的多线程编程入门
【10月更文挑战第29天】在Java的世界中,多线程就像是一场精心编排的交响乐。每个线程都是乐团中的一个乐手,他们各自演奏着自己的部分,却又和谐地共同完成整场演出。本文将带你走进Java多线程的世界,让你从零基础到能够编写基本的多线程程序。
29 1
|
1月前
|
算法 NoSQL Java
Springboot3新特性:GraalVM Native Image Support和虚拟线程(从入门到精通)
这篇文章介绍了Spring Boot 3中GraalVM Native Image Support的新特性,提供了将Spring Boot Web项目转换为可执行文件的步骤,并探讨了虚拟线程在Spring Boot中的使用,包括如何配置和启动虚拟线程支持。
82 9
Springboot3新特性:GraalVM Native Image Support和虚拟线程(从入门到精通)
|
23天前
|
Java 数据处理 开发者
Java多线程编程的艺术:从入门到精通####
【10月更文挑战第21天】 本文将深入探讨Java多线程编程的核心概念,通过生动实例和实用技巧,引导读者从基础认知迈向高效并发编程的殿堂。我们将一起揭开线程管理的神秘面纱,掌握同步机制的精髓,并学习如何在实际项目中灵活运用这些知识,以提升应用性能与响应速度。 ####
43 3
|
24天前
|
Java
Java中的多线程编程:从入门到精通
本文将带你深入了解Java中的多线程编程。我们将从基础概念开始,逐步深入探讨线程的创建、启动、同步和通信等关键知识点。通过阅读本文,你将能够掌握Java多线程编程的基本技能,为进一步学习和应用打下坚实的基础。
|
7天前
|
程序员 C# 图形学
全面的C#/.NET自学入门指南
全面的C#/.NET自学入门指南
|
1月前
|
存储 消息中间件 NoSQL
Redis 入门 - C#.NET Core客户端库六种选择
Redis 入门 - C#.NET Core客户端库六种选择
59 8