.NET进阶——深入理解线程(2)Thread入门到精通

Thread就是线程,我们在上文中已经给大家介绍了线程的等相关概念,不清楚的小伙伴可以看上文:.NET进阶------深入理解线程(1)同步异步与单线程多线程的区分今天我们来详细介绍一下多线程,多线程是.NET中非常重要的 - 掘金

本文紧接着上文来介绍关于Thread的相关知识。

一、如何创建线程

先思考一个问题:我们为什么要创建新的线程?

原因无非是需要新开一个线程来完成特殊的方法或者函数,你可以让新开的子线程异步执行,也可以让它同步执行,这都取决于你。但是不变的是,你肯定需要让新开的线程执行一些函数或者方法,否则你根本不需要新开线程。那么如何让新开的线程知道我要执行什么方法呢?

我们需要用到委托!!!

1.1 创建线程的两种方式

我们可以用Thread类,创建两种类型的线程,分别是无参数无返回值的线程 ,以及有参数无返回值的线程 ,区别就是接收的参数不一样,一个是ThreadStart类型的委托,另一个是ParameterizedThreadStart先看一下Thread类的两个构造函数:

cs 复制代码
// 无参构造方法
        public Thread(ThreadStart start)
        {
           // 内部的代码我们不需要看
        }
       
// 有参构造方法
        public Thread(ParameterizedThreadStart start)
        {
            // 内部的代码我们不需要看
        }

可见,在创建Thread线程之前,我们要先创建ThreadStart或者是ParameterizedThreadStart委托的实例,然后把实例传递到Thread中。

1.1.1 创建无参数无返回值的线程

创建无参数无返回值的线程,需要先创建一个符合ThreadStart委托的方法,也就是无参无返回值的方法,然后把方法装载到ThreadStart委托中,然后把委托实例放到Thread类里,作为构造函数的参数。

1. 什么是ThreadStart委托

ThreadStart是.NET 内置的、无返回值、无参数 的委托,专门用来封装线程要执行的 "无参数方法"。它的底层定义非常简单:

cs 复制代码
public delegate void ThreadStart();

翻译过来就是:这个委托只能绑定 "没有参数、返回值为 void" 的方法。

2. ThreadStart如何创建线程

只要线程执行的方法没有参数,就用ThreadStart把方法装进去(实际开发中通常省略显式写ThreadStart,编译器会自动推断,但是我们需要知道执行原理):

cs 复制代码
using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // 方式1:显式声明ThreadStart委托(新手易理解)
        ThreadStart ts = new ThreadStart(DoWork);
        Thread t1 = new Thread(ts);
        
        // 方式2:简化写法(实际开发常用,编译器自动转ThreadStart)
        Thread t2 = new Thread(DoWork);

        t1.Start();
        t2.Start();
        t1.Join();
        t2.Join();
    }

    // 符合ThreadStart要求:无参数、返回void
    static void DoWork()
    {
        Console.WriteLine($"线程{Thread.CurrentThread.ManagedThreadId}执行无参方法");
    }
}

1.1.2 创建有参数无返回值的线程

1. 什么是ParameterizedThreadStart委托 当我们需要新开一个线程,让这个新的线程去执行一个有参数无返回值的方法时,需要用到ParameterizedThreadStart委托,ParameterizedThreadStart是.NET 内置的、无返回值、带单个 object 参数 的委托,用来封装线程要执行的 "带参数方法"。底层定义:

cs 复制代码
public delegate void ParameterizedThreadStart(object obj);

翻译过来就是:这个委托只能绑定 "有且仅有一个 object 类型参数、返回值为 void" 的方法。

2. ParameterizedThreadStart如何创建线程

当线程执行的方法需要传参数时,就用这个委托,注意参数是object类型,内部使用需要装箱拆箱:

csharp 复制代码
using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // 方式1:显式声明(新手易理解)
        ParameterizedThreadStart pts = new ParameterizedThreadStart(DoWorkWithParam);
        Thread t1 = new Thread(pts);
        
        // 方式2:简化写法(实际开发常用)
        Thread t2 = new Thread(DoWorkWithParam);

        // 启动线程时传入参数(只能传一个object类型)
        t1.Start("线程1的参数");
        t2.Start(100); // 可以传任意类型,因为都是object的子类
        t1.Join();
        t2.Join();
    }

    // 符合ParameterizedThreadStart要求:单个object参数、返回void
    static void DoWorkWithParam(object obj)
    {
        Console.WriteLine($"线程{Thread.CurrentThread.ManagedThreadId}接收到参数:{obj},参数类型:{obj.GetType()}");
    }
}

1.1.3 更常用更高级的写法

由于创建线程时需要的参数是一个委托,我们可以用Lambda表达式传递,如果还记得Lambda表达式吗,实际上就是一个匿名方法,

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {

        static void Main(string[] args)
        {
            Thread thread1 = new Thread(() =>
            {
                Console.WriteLine("我是由ThreadStart创建的线程");
            });

            
            Thread thread2 = new Thread((obj) =>
            {
                var student = obj as Student;
                if (obj == null)
                {
                    Console.WriteLine("参数为空!");
                    return;
                }
                
                Console.WriteLine($"我是由ParameterizedThreadStart创建的线程,{student.Name}年龄为:{student.Age}");
                student.Age++;
                Console.WriteLine($"{student.Name}年龄增加了一岁:{student.Age}");
            });

            thread1.Start();
            Student mystudent = new Student() { Age = 12, Name = "光泽" };
            thread2.Start(mystudent);

            // 等待两个子线程执行完毕
            Thread.Sleep(5000);
        }
    }

    public class Student
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
}

为什么这里可以直接传递Lambda表达式呢? Lambda 表达式() => { ... }(obj) => { ... }并不是 "直接写方法",而是 C# 提供的语法糖 (编译器帮你简化代码):编译器会自动把 Lambda 表达式转换成对应的委托实例,不需要你手动new ThreadStart(...)new ParameterizedThreadStart(...)

针对 thread1 的无参 Lambda(对应 ThreadStart)

cs 复制代码
Thread thread1 = new Thread(() =>
{
    Console.WriteLine("我是由ThreadStart创建的线程");
});

编译器会自动做这些事:① 识别这个 Lambda 是「无参数、返回 void」的,符合ThreadStart委托的定义(public delegate void ThreadStart(););② 自动创建ThreadStart委托实例,并把 Lambda 里的代码作为委托的执行逻辑;③ 把这个委托实例传给Thread构造函数。

等价于编译器帮你生成了这样的代码(你不用写,但底层是这样的):

cs 复制代码
// 编译器自动生成的匿名方法
static void <Main>b__0()
{
    Console.WriteLine("我是由ThreadStart创建的线程");
}

// 编译器自动创建委托实例
ThreadStart ts = new ThreadStart(<Main>b__0);
Thread thread1 = new Thread(ts);

针对 thread2 的带参 Lambda(对应 ParameterizedThreadStart)

cs 复制代码
Thread thread2 = new Thread((obj) =>
{
    var student = obj as Student;
    // ... 后续逻辑
});

编译器同样会自动处理:① 识别这个 Lambda 是「单个 object 参数、返回 void」的,符合ParameterizedThreadStart委托的定义(public delegate void ParameterizedThreadStart(object obj););② 自动创建ParameterizedThreadStart委托实例,把 Lambda 逻辑封装进去;③ 把委托实例传给Thread构造函数。

等价于编译器生成:

cs 复制代码
// 编译器自动生成的匿名方法
static void <Main>b__1(object obj)
{
    var student = obj as Student;
    // ... 后续逻辑
}

// 自动创建委托实例
ParameterizedThreadStart pts = new ParameterizedThreadStart(<Main>b__1);
Thread thread2 = new Thread(pts);

二、线程的相关操作

2.1 Start ()方法

Start () ≠ 直接调用委托

如果只是 "调用委托方法",直接写委托实例().Invoke()就可以了(同步执行),但Start()的核心是 "创建新线程",然后再新的线程里面执行委托。

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {

        static void Main(string[] args)
        {
            Console.WriteLine($"主线程是:{Thread.CurrentThread.ManagedThreadId}");

            ThreadStart threadStart = () => Console.WriteLine($"正在执行的线程是:{Thread.CurrentThread.ManagedThreadId}");

            Thread thread =new Thread(threadStart);
            // 执行委托实例
            threadStart.Invoke();
            // 创建新线程,让新线程执行委托
            thread.Start();
        }
    }
}

执行结果

cs 复制代码
主线程是:1
正在执行的线程是:1
正在执行的线程是:7

可见,用threadStart.Invoke()直接调用委托是并不会改变线程的,线程ID不变,委托只会在主线程执行。但是,thread.Start()不同,他新开了一个线程,于是线程ID变了,这个新的线程里,执行了委托。

2.2 IsBackground属性

在讲 IsBackground属性之前,我们需要先回顾几个概念,进程、主线程、子线程分别是什么:

概念 比喻 核心作用
进程 一个正在运营的 "工厂" 操作系统分配资源的基本单位(独立内存空间、CPU 配额、文件句柄等),工厂有围墙,和其他工厂(进程)互不干扰
主线程 工厂的 "厂长" 进程启动时操作系统自动创建的第一个线程,是进程的 "入口"(比如 C# 的 Main 方法),负责统筹整个工厂的启动 / 核心逻辑
子线程 工厂里的 "工人" 由厂长(主线程)手动创建,帮厂长分担工作,和厂长并行干活,共享工厂的资源(内存、工具等),但有自己的 "工作台"(线程栈)

1. 进程是 "容器",线程是 "容器里的执行者"

  • 一个进程可以包含多个线程(主线程 + N 个子线程),但至少有一个线程(主线程);
  • 所有线程共享进程的资源(比如全局变量、静态变量、堆内存),但每个线程有独立的栈内存(存储局部变量、方法调用栈)------ 这也是线程安全问题的根源(多个工人抢同一个工具);
  • 线程不能脱离进程存在,线程的所有操作都基于所属进程的资源;进程退出后,所有线程都会被强制终止

2. 主线程是 "进程的入口,但不是进程的主宰"

  • 进程启动 → 操作系统自动创建主线程 → 主线程执行Main方法(C#)/main函数(C++),这是程序的入口;

  • 主线程的生命周期≠进程的生命周期:

    • 主线程执行完Main方法("厂长下班"),只要进程中还有前台子线程在运行("工人还在干活"),进程就不会退出;
    • 只有当所有前台线程都执行完,进程才会销毁,释放所有资源。

3. 子线程是 "主线程的助手,分前台 / 后台两种身份"

  • 子线程由主线程通过new Thread()创建,和主线程并行执行(CPU 调度下同时干活);
  • 子线程默认是前台线程("正式工"),进程必须等所有正式工干完活才关门;
  • 子线程可设为后台线程("临时工"),进程不会等临时工,只要正式工都走了,不管临时工有没有干完活,工厂直接关门,临时工被强制终止。
线程类型 进程是否等待? 进程退出时的行为 示例(结合你的代码)
前台线程 是(必须等) 进程会等待该线程执行完毕后退出 你代码中new Thread()创建的默认前台线程,主线程结束后进程等它打印 ID
后台线程 否(不等) 进程退出时强制终止该线程 thread.IsBackground = true,主线程结束后进程直接退出,子线程可能来不及打印

补充:主线程的默认身份

主线程本身也是前台线程(厂长是正式工),所以如果主线程一直运行(比如死循环),即使没有子线程,进程也会一直运行。

所以,当我们把之前的代码改成这样后,新创建的线程变成了后台线程(临时工),而进程(工厂)不会等临时工,所以会直接关厂关门。

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {

        static void Main(string[] args)
        {
            Console.WriteLine($"主线程是:{Thread.CurrentThread.ManagedThreadId}");

            ThreadStart threadStart = () =>
            {
                Thread.Sleep(3000);
                Console.WriteLine($"正在执行的线程是:{Thread.CurrentThread.ManagedThreadId}");
            };

            Thread thread = new Thread(threadStart);
            thread.IsBackground = true;
            thread.Start();
        }
    }
}

执行结果:

cs 复制代码
主线程是:1

由于 thread.IsBackground = true,把子线程从默认的前台线程变成了后台线程,用我们刚刚的话说就是把thread从正式工变成了临时工。由于工厂不会等待临时工,所以在主线程,也就是唯一的前台线程执行完毕后,进程就关闭了,所以这个临时工干的活老板根本不承认,于是就打印不出来。

2.3 Join()方法

1 核心定义

Join()的本质是:让调用这个线程的上级线程等这个线程执行完毕,上级线程才能接着走

用之前的 "工厂(进程)- 厂长(主线程)- 工人(子线程)" 比喻:

  • 工人(子线程)喊 "老板,我干完活你再走" → 就是workerThread.Join()
  • 厂长会站在原地等工人干完活,哪怕工人要干 3 小时,厂长也不挪步;
  • 等工人干完,厂长才继续自己的收尾工作(比如锁门、结账)。

Join()有 2 个重载,满足不同等待需求,核心区别是 "是否设置超时":

方法签名 作用 返回值
void Join() 无限等待,直到子线程执行完毕(哪怕子线程跑 1 小时,主线程也等)
bool Join(int ms) 等待指定毫秒数(ms),超时后不再等 true = 子线程执行完;f

比如还是上面那个设置成后台线程的例子,只要加上Join()后,主线程就必须等待子线程了,不能自己先执行结束。

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {

        static void Main(string[] args)
        {
            Console.WriteLine($"主线程是:{Thread.CurrentThread.ManagedThreadId}");

            ThreadStart threadStart = () =>
            {
                Thread.Sleep(3000);
                Console.WriteLine($"正在执行的线程是:{Thread.CurrentThread.ManagedThreadId}");
            };

            Thread thread = new Thread(threadStart);
            thread.IsBackground = true;
            thread.Start();
            thread.Join();
        }
    }
}

执行结果:

cs 复制代码
主线程是:1
正在执行的线程是:7

2.4 执行顺序

问题描述:如果有两个线程,我要控制两个线程的执行顺序,如何实现?

错误版本

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {

        static void Main(string[] args)
        {
            ThreadStart threadStart1 = () =>
            {
                Console.WriteLine("我是先执行的线程");
            };
            Thread thread1 = new Thread(threadStart1);

            ThreadStart threadStart2 = () =>
            {
                Console.WriteLine("我是后执行的线程");

            };
            Thread thread2 = new Thread(threadStart2);

            thread1.Start();
            thread2.Start();

        }
    }
}

执行结果:

复制代码
我是后执行的线程
我是先执行的线程

结果很崩溃啊,明明是线程1先执行Start(),结果却是线程1在下面。这是为什么呢?

thread1.Start()不是 "让 thread1 立刻跑",只是给操作系统递了个 "执行申请"(线程进等待队列);CPU 调度器选线程时,不看 "谁先递申请",只看当下系统状态(比如 CPU 空闲、线程优先级),随机挑一个先跑,所以 thread2 可能先被选中。重复执行这个代码,结果会发生变化。

改进后的代码:

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {

        static void Main(string[] args)
        {
            ThreadStart threadStart1 = () =>
            {
                Console.WriteLine("我是先执行的线程");
            };
            Thread thread1 = new Thread(threadStart1);

            ThreadStart threadStart2 = () =>
            {
                Console.WriteLine("我是后执行的线程");

            };
            Thread thread2 = new Thread(threadStart2);

            // 方法1
            thread1.Start();
            thread1.Join();
            thread2.Start();
            thread2.Join();

            // 方法2
            ThreadStart threadStart3 = threadStart1;
            threadStart3 += threadStart2;
            Thread thread3 = new Thread(threadStart3);
            thread3.Start();
        }
    }
}

这两者方法各有优劣,但是都可以实现控制线程执行顺序。

2.5 带返回值的线程封装

1. 先认清原生 Thread 的核心痛点

.NET 原生Thread的构造函数仅接收ThreadStart(签名:void ())或ParameterizedThreadStart(签名:void (object))委托,无法直接执行带返回值的方法(如int GetYear())。

错误示例代码

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {
        static void Main(string[] args)
        {
            // 报错原因:ThreadStart要求无返回值(void),但GetYear返回int,签名不匹配
            ThreadStart threadStart = new ThreadStart(GetYear()); 
        }

        // 带返回值的业务逻辑:获取当前年份
        static int GetYear()
        {
            return DateTime.Now.Year;
        }
    }
}

👉 核心问题 :原生Thread不支持带返回值的委托,无法直接执行GetYear这类有返回值的逻辑。

2. 解决 "返回值 + 解耦" 问题

2.1 初步解决:变量暂存返回值(耦合版)

通过 "外部变量暂存结果 + Join等待线程执行",解决返回值问题,但方法与具体业务逻辑(GetYear)强耦合,复用性差。

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {
        static void Main(string[] args)
        {
            // 调用方法时直接阻塞主线程,直到子线程返回结果
            int result = ThreadWithReturn();
            Console.WriteLine($"当前年份:{result}");
        }

        // 业务逻辑:获取当前年份
        static int GetYear()
        {
            Thread.Sleep(1000); // 模拟耗时操作
            return DateTime.Now.Year;
        }

        // 耦合版:仅支持GetYear逻辑,复用性差
        public static int ThreadWithReturn()
        {
            int result = default(int); // 定义变量存子线程结果
            // 把带返回值的逻辑包进无返回值的ThreadStart
            ThreadStart threadStart = new ThreadStart(() =>
            {
                result = GetYear(); // 子线程执行业务逻辑,结果存到外部变量
            });
            
            Thread thread = new Thread(threadStart);
            thread.Start();       // 启动子线程
            thread.Join();        // 立刻阻塞主线程,等子线程执行完
            return result;        // 返回结果
        }
    }
}

✅ 解决:原生Thread无返回值的问题;❌ 新问题:方法与GetYear强耦合(换业务逻辑需重写方法),且Join立刻阻塞主线程,灵活性差。

2.2 优化:泛型解耦(通用版)

通过Func<T>委托接收任意带返回值的业务逻辑,封装成泛型方法,解决耦合问题。

cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {
        static void Main(string[] args)
        {
            // 1. 把业务逻辑封装成Func<T>委托(支持任意返回值类型)
            Func<int> func = new Func<int>(GetYear);
            
            // 2. 调用通用方法,直接获取结果(但主线程会立刻阻塞)
            int result = ThreadWithReturn(func);
            Console.WriteLine($"当前年份:{result}");
        }

        // 业务逻辑:获取当前年份(可替换为任意带返回值的方法)
        static int GetYear()
        {
            Thread.Sleep(1000); // 模拟耗时操作
            return DateTime.Now.Year;
        }

        // 通用泛型版:支持任意返回值类型,解耦业务逻辑
        public static T ThreadWithReturn<T>(Func<T> func)
        {
            T result = default(T); // 泛型变量存子线程结果
            ThreadStart threadStart = new ThreadStart(() =>
            {
                result = func.Invoke(); // 子线程执行传入的业务逻辑
            });
            
            Thread thread = new Thread(threadStart);
            thread.Start();       // 启动子线程
            thread.Join();        // 仍会立刻阻塞主线程
            return result;        // 返回子线程结果
        }
    }
}

✅ 解决:原生Thread无返回值 + 方法耦合问题;❌ 新问题:thread.Join()在启动线程后立刻执行,主线程被强制阻塞(哪怕暂时不需要结果,也得等子线程执行完),比如想先打印 "线程已启动" 再等结果,完全做不到。

3. 核心优化:延迟 Join(按需阻塞)

3.1 优化思路

把 "等待线程(Join)+ 取结果" 的逻辑封装成Func<T>委托返回给主线程:

  • 启动子线程时,主线程不阻塞(可先执行其他操作,如打印提示);
  • 只有主线程真正需要结果时 ,调用返回的委托,才执行Join阻塞。
3.2 最终优化版代码
cs 复制代码
namespace Advanced_Thread_03
{
    public class Program
    {
        static void Main(string[] args)
        {
            // 1. 封装业务逻辑为Func<T>委托(支持任意返回值类型)
            Func<int> yearFunc = new Func<int>(GetYear);
            
            // 2. 调用通用方法,获取"延迟取结果"的委托(此时仅启动子线程,不阻塞)
            Func<int> getResultFunc = ThreadWithReturn(yearFunc);
            
            // 3. 主线程可先执行其他操作(无阻塞)
            Console.WriteLine("子线程已启动,主线程先执行其他逻辑~~~");
            Thread.Sleep(500); // 模拟主线程的其他操作
            
            // 4. 真正需要结果时,调用委托(此时才执行Join,阻塞主线程等结果)
            int currentYear = getResultFunc.Invoke();
            Console.WriteLine($"当前年份:{currentYear}");
        }

        // 业务逻辑:获取当前年份(可替换为任意带返回值的耗时操作)
        static int GetYear()
        {
            Thread.Sleep(1000); // 模拟耗时操作(如数据库查询、计算)
            return DateTime.Now.Year;
        }

        /// <summary>
        /// 通用带返回值线程封装:延迟阻塞,按需取结果
        /// </summary>
        /// <typeparam name="T">返回值类型</typeparam>
        /// <param name="func">带返回值的业务逻辑委托</param>
        /// <returns>延迟取结果的委托(调用时才阻塞等结果)</returns>
        public static Func<T> ThreadWithReturn<T>(Func<T> func)
        {
            T result = default(T); // 闭包变量:存子线程执行结果
            Thread thread = new Thread(new ThreadStart(() =>
            {
                result = func.Invoke(); // 子线程执行业务逻辑,结果存入闭包变量
            }));
            
            thread.Start(); // 启动子线程(立刻返回,不阻塞)

            // 返回"延迟取结果"的委托:调用时才Join等待
            return new Func<T>(() =>
            {
                thread.Join(); // 调用委托时,阻塞当前线程(主线程)等子线程
                return result; // 返回子线程的执行结果
            });
        }
    }
}

3.3 执行结果

复制代码
子线程已启动,主线程先执行其他逻辑~~~
当前年份:2025

3.4 核心改进说明

执行阶段 主线程状态 关键逻辑
调用ThreadWithReturn 无阻塞 仅启动子线程,返回 "延迟取结果" 委托
主线程执行其他操作 无阻塞 子线程后台执行耗时逻辑,主线程并行处理自己的任务
调用getResultFunc.Invoke() 阻塞(按需) 执行Join等待子线程,获取结果后恢复执行

总结: 一定要清楚的明白,我们使用多线程是为了什么,主要就是为了实现异步,而异步和同步的最核心区别就是,同步会傻等,不管要不要用到都会傻等,而异步会按需等,甚至是不等。

相关推荐
民乐团扒谱机9 小时前
【微实验】基于Python实现的实时键盘鼠标触控板拾取检测(VS2019,附完整代码)
python·c#·计算机外设
CreasyChan9 小时前
Unity中C#状态模式详解
unity·c#·状态模式
一个帅气昵称啊9 小时前
.Net——AI智能体开发基于 Microsoft Agent Framework 实现第三方聊天历史存储
人工智能·microsoft·.net
工程师0079 小时前
线程同步的意义
c#·锁机制·线程同步
yugi9878389 小时前
基于C#实现的WiFi信号强度扫描程序
开发语言·c#
sali-tec10 小时前
C# 基于halcon的视觉工作流-章70 深度学习-Deep OCR
开发语言·人工智能·深度学习·算法·计算机视觉·c#·ocr
武藤一雄10 小时前
C#中常见集合都有哪些?
开发语言·微软·c#·.net·.netcore
唐青枫10 小时前
C#.NET struct 全解析:什么时候该用值类型?
c#·.net