一、多线程基础概念
(一)线程与进程
-
进程:是操作系统分配资源的基本单位,它包含了程序运行所需的所有资源,如内存空间、文件句柄等。每个进程都有自己的独立内存空间,一个程序运行时会启动一个进程。
-
线程:是进程中的一个执行单元,是操作系统能够进行运算调度的最小单位。一个进程可以包含多个线程,线程共享进程的资源,但每个线程有自己的执行路径。
(二)多线程的意义
-
提高程序效率:在多核处理器时代,多线程可以充分利用多核处理器的计算能力,让程序同时执行多个任务,从而提高程序的运行效率。例如,一个程序同时进行文件读取和数据处理,可以通过多线程让这两个任务并行执行,减少总运行时间。
-
改善用户体验:在图形用户界面(GUI)程序中,多线程可以避免程序界面在执行长时间任务时出现卡顿。例如,一个文件下载程序,可以使用一个线程专门用于下载文件,而主线程仍然可以响应用户的界面操作,如取消下载等。
二、C# 中创建线程的方式
(一)使用 Thread 类
cs
using System;
using System.Threading;
class Program
{
static void Main()
{
// 创建一个线程
Thread myThread = new Thread(new ThreadStart(MyMethod));
// 启动线程
myThread.Start();
// 主线程继续执行
Console.WriteLine("主线程正在执行...");
// 等待子线程结束
myThread.Join();
Console.WriteLine("子线程已结束,主线程继续执行...");
}
static void MyMethod()
{
Console.WriteLine("子线程正在执行...");
}
}
-
ThreadStart :是一个委托,用于指定线程要执行的方法。在这个例子中,
MyMethod
方法就是子线程要执行的任务。 -
Thread 类的构造函数 :可以接受一个 ThreadStart 委托作为参数,也可以在创建线程后通过
Thread.Start()
方法指定要执行的方法。 -
Thread.Start():用于启动线程。一旦调用这个方法,线程就会开始执行指定的方法。
-
Thread.Join() :用于主线程等待子线程结束。如果不调用
Join()
,主线程可能会在子线程执行完成之前就结束,导致程序提前退出。
(二)使用 Task 类
cs
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
// 创建一个任务
Task myTask = new Task(MyMethod);
// 启动任务
myTask.Start();
// 主线程继续执行
Console.WriteLine("主线程正在执行...");
// 等待任务结束
myTask.Wait();
Console.WriteLine("任务已结束,主线程继续执行...");
}
static void MyMethod()
{
Console.WriteLine("任务正在执行...");
}
}
-
Task 类 :是 .NET Framework 4.0 引入的,它提供了更高级的线程管理功能。
Task
类可以看作是对线程的封装,它提供了更方便的线程启动、等待和异常处理等功能。 -
Task.Start():用于启动任务。
-
Task.Wait():用于等待任务完成。
(三)使用 Lambda 表达式
cs
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
// 使用 Lambda 表达式创建并启动任务
Task.Run(() =>
{
Console.WriteLine("任务正在执行...");
});
Console.WriteLine("主线程正在执行...");
}
}
- Task.Run() :是
Task
类的静态方法,它可以直接启动一个任务,而不需要显式创建Task
对象。Lambda 表达式可以很方便地定义任务要执行的代码。
三、线程的生命周期
(一)生命周期阶段
-
新建(Created) :线程对象被创建,但尚未启动。此时调用
Thread.Start()
方法可以启动线程。 -
就绪(Runnable):线程已经启动,等待操作系统分配 CPU 时间片。一旦获得 CPU 时间片,线程就会进入运行状态。
-
运行(Running):线程正在执行。
-
阻塞(Blocked):线程因为某些原因暂时无法运行,例如等待 I/O 操作完成、等待锁释放等。
-
终止(Terminated):线程执行完成或者被强制终止。
(二)线程状态的控制
-
线程的睡眠(Sleep) :可以通过
Thread.Sleep()
方法让线程暂停一段时间。例如,Thread.Sleep(1000)
表示让线程暂停 1000 毫秒。 -
线程的中断(Interrupt) :可以通过
Thread.Interrupt()
方法中断线程的睡眠或等待状态。如果线程处于阻塞状态,调用Interrupt()
方法会抛出ThreadInterruptedException
异常。 -
线程的优先级(Priority) :可以通过设置
Thread.Priority
属性来调整线程的优先级。优先级高的线程会优先获得 CPU 时间片,但优先级只是线程调度的一个参考因素。
四、线程同步
(一)为什么要同步
- 在多线程环境中,多个线程可能会同时访问共享资源(如变量、文件等)。如果没有适当的同步机制,可能会导致数据不一致、竞态条件等问题。例如,两个线程同时对一个变量进行加 1 操作,可能会出现最终结果不符合预期的情况。
(二)同步机制
- 锁(Lock)
cs
using System;
using System.Threading;
class Program
{
static int count = 0;
static readonly object lockObj = new object();
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("最终计数:" + count);
}
static void Increment()
{
for (int i = 0; i < 10000; i++)
{
lock (lockObj)
{
count++;
}
}
}
}
-
lock 关键字 :用于锁定一个代码块,确保在某个时刻只有一个线程可以执行该代码块。
lock
关键字会锁定一个对象(在这个例子中是lockObj
),其他线程如果要执行被锁定的代码块,必须等待当前线程释放锁。 -
锁的作用范围 :锁的作用范围是代码块,只有在
lock
代码块内的代码才会受到锁的保护。在这个例子中,count++
操作被锁定,确保了每次只有一个线程可以修改count
变量。
- 互斥体(Mutex)
cs
using System;
using System.Threading;
class Program
{
static int count = 0;
static Mutex mutex = new Mutex();
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("最终计数:" + count);
}
static void Increment()
{
for (int i = 0; i < 10000; i++)
{
mutex.WaitOne(); // 请求互斥体
count++;
mutex.ReleaseMutex(); // 释放互斥体
}
}
}
-
Mutex 类 :互斥体是一种同步机制,它可以用于线程同步和进程同步。
Mutex.WaitOne()
方法用于请求互斥体,如果互斥体已经被其他线程占用,则当前线程会阻塞等待;Mutex.ReleaseMutex()
方法用于释放互斥体。 -
互斥体与锁的区别:互斥体可以用于跨进程同步,而锁只能用于线程同步。互斥体的性能通常比锁稍差,因为互斥体需要操作系统级别的支持。
- 信号量(Semaphore)
cs
using System;
using System.Threading;
class Program
{
static int count = 0;
static Semaphore semaphore = new Semaphore(2, 2); // 最大同时允许两个线程进入
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
Thread t3 = new Thread(Increment);
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
Console.WriteLine("最终计数:" + count);
}
static void Increment()
{
for (int i = 0; i < 10000; i++)
{
semaphore.WaitOne(); // 请求信号量
count++;
semaphore.Release(); // 释放信号量
}
}
}
-
Semaphore 类 :信号量是一种同步机制,它可以限制同时访问共享资源的线程数量。在构造函数中,第一个参数表示初始信号量数量,第二个参数表示最大信号量数量。
Semaphore.WaitOne()
方法用于请求信号量,如果信号量数量不足,线程会阻塞等待;Semaphore.Release()
方法用于释放信号量。 -
信号量的用途:信号量常用于限制对有限资源的访问,例如限制同时访问数据库连接的数量。
五、线程安全的集合
(一)为什么需要线程安全的集合
- 在多线程环境中,如果多个线程同时对集合进行读写操作,可能会导致数据不一致或程序崩溃。例如,一个线程正在遍历集合,另一个线程同时修改集合,可能会导致遍历操作出现异常。
(二)线程安全的集合类型
- ConcurrentQueue
cs
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
class Program
{
static void Main()
{
ConcurrentQueue<int> queue = new ConcurrentQueue<int>();
Task t1 = Task.Run(() =>
{
for (int i = 0; i < 10000; i++)
{
queue.Enqueue(i);
}
});
Task t2 = Task.Run(() =>
{
int item;
while (queue.TryDequeue(out item))
{
Console.WriteLine(item);
}
});
Task.WaitAll(t1, t2);
}
}
- ConcurrentQueue :线程安全的队列。
Enqueue
方法用于入队,TryDequeue
方法用于出队。TryDequeue
方法会返回一个布尔值,表示是否成功出队。
- ConcurrentDictionary
cs
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
class Program
{
static void Main()
{
ConcurrentDictionary<int, string> dictionary = new ConcurrentDictionary<int, string>();
Task t1 = Task.Run(() =>
{
for (int i = 0; i < 10000; i++)
{
dictionary[i] = "Value" + i;
}
});
Task t2 = Task.Run(() =>
{
foreach (var item in dictionary)
{
Console.WriteLine(item);
}
});
Task.WaitAll(t1, t2);
}
}
- ConcurrentDictionary :线程安全的字典。它提供了线程安全的添加、删除和查找操作。例如,
dictionary[i] = "Value" + i
用于添加或更新键值对。
六、线程池
(一)线程池的概念
- 线程池是一种线程管理机制,它预先创建了一组线程,当程序需要执行任务时,可以从线程池中获取一个线程来执行任务,任务执行完成后,线程会返回线程池,等待下一次任务。线程池可以减少线程创建和销毁的开销,提高程序的性能。
(二)线程池的使用
cs
using System;
using System.Threading;
class Program
{
static void Main()
{
// 将任务提交到线程池
ThreadPool.QueueUserWorkItem(new WaitCallback(MyMethod));
Console.WriteLine("主线程正在执行...");
Thread.Sleep(2000); // 等待任务执行完成
Console.WriteLine("主线程结束");
}
static void MyMethod(object state)
{
Console.WriteLine("线程池中的任务正在执行...");
}
}
-
ThreadPool.QueueUserWorkItem() :用于将任务提交到线程池。
WaitCallback
是一个委托,用于指定任务要执行的方法。 -
线程池的特点:线程池中的线程是可复用的,任务执行完成后,线程会返回线程池,等待下一次任务。线程池会根据系统资源和任务数量动态调整线程数量。
七、异步编程
(一)异步编程的概念
- 异步编程是一种编程模式,它允许程序在执行某个任务时,不阻塞主线程,而是让主线程继续执行其他任务。当异步任务完成时,程序可以得到通知并处理结果。异步编程可以提高程序的响应性和性能。
(二)异步编程的实现方式
- async 和 await 关键字
cs
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Console.WriteLine("主线程开始执行...");
// 调用异步方法
string result = await MyAsyncMethod();
Console.WriteLine("异步方法返回结果:" + result);
Console.WriteLine("主线程结束");
}
static async Task<string> MyAsyncMethod()
{
Console.WriteLine("异步方法开始执行...");
await Task.Delay(2000); // 模拟异步操作
return "Hello, World!";
}
}
-
async 关键字 :用于标记一个方法是异步的。异步方法可以返回
Task
或Task<T>
类型。 -
await 关键字 :用于等待异步操作完成。
await
关键字会暂停当前方法的执行,直到异步操作完成。await
关键字只能用在async
方法中。 -
Task.Delay() :用于模拟异步操作,它会返回一个
Task
对象,表示延迟操作。
- 异步编程的优点
-
提高程序响应性:在执行长时间操作时,不会阻塞主线程,程序可以继续响应用户的操作。
-
提高程序性能:异步编程可以充分利用多核处理器的计算能力,提高程序的性能。
八、多线程的调试和异常处理
(一)调试多线程程序
-
在调试多线程程序时,可以使用 Visual Studio 的调试工具。例如,在调试窗口中可以查看当前线程的状态、线程的调用栈等信息。
-
可以设置断点来观察线程的执行情况,也可以使用日志输出来记录线程的执行过程。
(二)多线程的异常处理
-
在多线程程序中,异常处理非常重要。线程中抛出的异常如果没有被捕获,可能会导致线程终止,甚至导致程序崩溃。
-
在线程中捕获异常可以使用
try-catch
块。例如:csusing System; using System.Threading; class Program { static void Main() { Thread myThread = new Thread(MyMethod); myThread.Start(); myThread.Join(); } static void MyMethod() { try { // 可能会抛出异常的代码 throw new Exception("线程中发生异常"); } catch (Exception ex) { Console.WriteLine("捕获异常:" + ex.Message); } } }
-
在
Task
中捕获异常可以使用Task.ContinueWith()
方法。例如:csusing System; using System.Threading.Tasks; class Program { static void Main() { Task myTask = new Task(MyMethod); myTask.Start(); myTask.ContinueWith(t => { if (t.IsFaulted) { Console.WriteLine("捕获异常:" + t.Exception.InnerException.Message); } }); myTask.Wait(); } static void MyMethod() { throw new Exception("任务中发生异常"); } }
九、总结
-
多线程编程是现代编程中非常重要的一部分,它可以提高程序的性能和响应性。在 C# 中,可以通过多种方式创建线程,如使用
Thread
类、Task
类等。线程同步是多线程编程中一个关键问题,可以通过锁、互斥体、信号量等机制来解决。线程安全的集合可以方便地在多线程环境中使用。线程池可以提高线程的复用性,减少线程创建和销毁的开销。异步编程可以进一步提高程序的响应性和性能。在开发多线程程序时,需要注意调试和异常处理,以确保程序的稳定性和可靠性。
题外话:
可能有小伙伴说多线程与异步编程看起来这么相像的,下面来分析下它们的异同点
异步编程与多线程的异同点讨论
一、相似之处
(一)目的
-
提高程序效率:多线程和异步编程都可以让程序同时执行多个任务,从而提高程序的运行效率。例如,在处理 I/O 操作(如文件读写、网络请求)时,多线程和异步编程都可以避免程序在等待 I/O 操作完成时浪费时间。
-
改善用户体验:在图形用户界面(GUI)程序中,多线程和异步编程都可以避免程序界面在执行长时间任务时出现卡顿,从而改善用户体验。
(二)实现方式
- 都可以在后台执行任务:多线程和异步编程都可以让程序在后台执行任务,而主线程可以继续执行其他操作。例如,使用多线程或异步编程都可以实现一个程序在后台下载文件,同时主线程仍然可以响应用户的界面操作。
二、区别
(一)概念
-
多线程:是指一个程序中同时运行多个线程。每个线程都是一个独立的执行路径,可以同时执行不同的任务。多线程的核心是线程的并发执行,多个线程可以共享程序的资源(如内存、文件句柄等)。
-
异步编程 :是一种编程模式,它允许程序在执行某个任务时,不阻塞主线程,而是让主线程继续执行其他任务。当异步任务完成时,程序可以得到通知并处理结果。异步编程的核心是任务的异步执行,它通过事件、回调或
await
等机制来实现。
(二)实现机制
-
多线程 :通过创建多个线程来实现并发执行。在 C# 中,可以使用
Thread
类、Task
类或线程池来创建线程。线程的创建和销毁会消耗一定的系统资源,线程之间的切换也需要操作系统支持。 -
异步编程 :通过事件、回调或
await
等机制来实现任务的异步执行。在 C# 中,可以使用async
和await
关键字来实现异步方法。异步编程不需要创建新的线程,它通过事件循环或任务调度器来管理任务的执行。
(三)适用场景
多线程:
-
CPU 密集型任务:如果任务需要大量的 CPU 计算,如图像处理、数据加密等,使用多线程可以让多个 CPU 核心同时工作,从而提高程序的性能。
-
需要共享资源的任务:如果多个任务需要共享程序的资源,如内存、文件句柄等,使用多线程可以方便地实现资源共享。
异步编程:
- I/O 密集型任务:如果任务需要大量的 I/O 操作,如文件读写、网络请求等,使用异步编程可以避免程序在等待 I/O 操作完成时浪费时间,从而提高程序的响应性和性能。
- 需要高响应性的任务:在图形用户界面(GUI)程序中,使用异步编程可以避免程序界面在执行长时间任务时出现卡顿,从而改善用户体验。
(四)线程管理
-
多线程:需要手动管理线程的生命周期,包括线程的创建、启动、同步和销毁。线程之间的同步是一个复杂的问题,需要使用锁、互斥体、信号量等机制来避免数据竞争和死锁。
-
异步编程 :不需要手动管理线程,任务的执行由事件循环或任务调度器来管理。异步编程的同步机制相对简单,主要通过事件、回调或
await
等机制来实现。
(五)性能开销
-
多线程:线程的创建和销毁会消耗一定的系统资源,线程之间的切换也需要操作系统支持。如果线程数量过多,可能会导致系统性能下降。
-
异步编程:异步编程不需要创建新的线程,任务的执行由事件循环或任务调度器来管理,因此性能开销相对较小。
三、为什么它们看起来相似
(一)应用场景的重叠
- 在某些应用场景中,多线程和异步编程都可以用来提高程序的效率和响应性。例如,在处理 I/O 操作时,多线程和异步编程都可以避免程序在等待 I/O 操作完成时浪费时间。这种应用场景的重叠让它们看起来很相似。
(二)实现方式的相似性
- 在 C# 中,
Task
类既可以用于多线程编程,也可以用于异步编程。Task
类提供了线程池支持,可以方便地创建线程来执行任务;同时,Task
类也支持异步编程,可以通过async
和await
关键字来实现任务的异步执行。这种实现方式的相似性也让它们看起来很相似。
四、总结
虽然多线程和异步编程在某些方面看起来很相似,但它们是两个不同的概念。多线程的核心是线程的并发执行,适用于 CPU 密集型任务和需要共享资源的任务;异步编程的核心是任务的异步执行,适用于 I/O 密集型任务和需要高响应性的任务。在实际开发中,可以根据具体的任务类型和应用场景选择合适的编程方式。