C#基础之多线程讲解
1 多线程
1.1 简介
1.1.1 进程&线程
线程与进程区别:
- 进程(
Process
)是Windows系统中的一个基本概念,它包含着一个运行程序所需要的资源。一个正在运行的应用程序在操作系统中被视为一个进程,进程可以包括一个或多个线程。线程是操作系统分配处理器时间的基本单元,在进程中可以有多个线程同时执行代码。进程之间是相对独立的,一个进程无法访问另一个进程的数据(除非利用分布式计算方式),一个进程运行的失败也不会影响其他进程的运行,Windows系统就是利用进程把工作划分为多个独立的区域的。进程可以理解为一个程序的基本边界。是应用程序的一个运行例程,是应用程序的一次动态执行过程。 - 线程(
Thread
)是进程中的基本执行单元,是操作系统分配CPU时间的基本单位,一个进程可以包含若干个线程,在进程入口执行的第一个线程
被视为这个进程的主线程
。在.NET
应用程序中,都是以Main()
方法作为入口的,当调用此方法时系统就会自动创建一个主线程。线程主要是由CPU寄存器、调用栈和线程本地存储器(Thread Local Storage,TLS)组成的。CPU寄存器主要记录当前所执行线程的状态,调用栈主要用于维护线程所调用到的内存与数据,TLS主要用于存放线程的状态信息。
1.1.2 线程优缺点
多线程的优点:可以同时完成多个任务;可以使程序的响应速度更快;可以让占用大量处理时间的任务或当前没有进行处理的任务定期将处理时间让给别的任务;可以随时停止任务;可以设置每个任务的优先级以优化程序性能。
缺点:
- 线程也是程序,所以线程需要占用内存,线程越多,占用内存也越多。
- 多线程需要协调和管理,所以需要占用CPU时间以便跟踪线程。
- 线程之间对共享资源的访问会相互影响,必须解决争用共享资源的问题。
- 线程太多会导致控制太复杂,最终可能造成很多程序缺陷。
1.1.3 主线程
在 C#
中,System.Threading.Thread
类用于线程的工作。创建并访问多线程应用程序中的单个线程。进程中第一个被执行的线程称为主线程
。
当 C# 程序开始执行时,主线程自动创建。使用 Thread
类创建的线程被主线程的子线程调用。可以使用 Thread 类的 CurrentThread
属性访问线程。
下面的程序演示了主线程的执行:
using System;
using System.Threading;
namespace MultithreadingApplication
{
class MainThreadProgram
{
static void Main(string[] args)
{
Thread th = Thread.CurrentThread;
th.Name = "MainThread";
Console.WriteLine("This is {0}", th.Name);
Console.ReadKey();
}
}
}
结果:
This is MainThread
1.2 线程生命周期
线程生命周期开始于 System.Threading.Thread
类的对象被创建时,结束于线程被终止或完成执行时。
下面列出了线程生命周期中的各种状态:
未启动状态
:当线程实例被创建但Start
方法未被调用时的状况。就绪状态
:当线程准备好运行并等待 CPU 周期时的状况。不可运行状态
:下面的几种情况下线程是不可运行的:
已经调用 Sleep 方法
已经调用 Wait 方法
通过 I/O 操作阻塞死亡状态
:当线程已完成执行或已中止时的状况。
1.3 常用属性和方法
下表列出了 Thread 类的一些常用的 属性:
属性 | 描述 |
---|---|
CurrentContext | 获取线程正在其中执行的当前上下文 |
CurrentCulture | 获取或设置当前线程的区域性 |
CurrentPrincipal | 获取或设置线程的当前负责人(对基于角色的安全性而言) |
CurrentThread | 获取当前正在运行的线程 |
CurrentUICulture | 获取或设置资源管理器使用的当前区域性以便在运行时查找区域性特定的资源 |
ExecutionContext | 获取一个 ExecutionContext 对象,该对象包含有关当前线程的各种上下文的信息 |
IsAlive | 获取一个值,该值指示当前线程的执行状态 |
IsBackground | 获取或设置一个值,该值指示某个线程是否为后台线程 |
IsThreadPoolThread | 获取一个值,该值指示线程是否属于托管线程池 |
ManagedThreadId | 获取当前托管线程的唯一标识符 |
Name | 获取或设置线程的名称 |
Priority | 获取或设置一个值,该值指示线程的调度优先级 |
ThreadState | 获取一个值,该值包含当前线程的状态 |
下表列出了 Thread 类的一些常用的 方法:
方法名 | 描述 |
---|---|
public void Abort() | 在调用此方法的线程上引发 ThreadAbortException,以开始终止此线程的过程。调用此方法通常会终止线程 |
public static LocalDataStoreSlot AllocateDataSlot() | 在所有的线程上分配未命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段 |
public static LocalDataStoreSlot AllocateNamedDataSlot( string name) | 在所有线程上分配已命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段 |
public static void BeginCriticalRegion() | 通知主机执行将要进入一个代码区域,在该代码区域内线程中止或未经处理的异常的影响可能会危害应用程序域中的其他任务 |
public static void BeginThreadAffinity() | 通知主机托管代码将要执行依赖于当前物理操作系统线程的标识的指令 |
public static void EndCriticalRegion() | 通知主机执行将要进入一个代码区域,在该代码区域内线程中止或未经处理的异常仅影响当前任务 |
public static void EndThreadAffinity() | 通知主机托管代码已执行完依赖于当前物理操作系统线程的标识的指令 |
public static void FreeNamedDataSlot(string name) | 为进程中的所有线程消除名称与槽之间的关联。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段 |
public static Object GetData( LocalDataStoreSlot slot ) | 在当前线程的当前域中从当前线程上指定的槽中检索值。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段 |
public static AppDomain GetDomain() | 返回当前线程正在其中运行的当前域 |
public static AppDomain GetDomainID() | 返回唯一的应用程序域标识符 |
public static LocalDataStoreSlot GetNamedDataSlot( string name ) | 查找已命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段 |
public void Interrupt() | 中断处于 WaitSleepJoin 线程状态的线程 |
public void Join() | 在继续执行标准的 COM 和 SendMessage 消息泵处理期间,阻塞调用线程,直到某个线程终止为止。此方法有不同的重载形式 |
public static void MemoryBarrier() | 按如下方式同步内存存取:执行当前线程的处理器在对指令重新排序时,不能采用先执行 MemoryBarrier 调用之后的内存存取,再执行 MemoryBarrier 调用之前的内存存取的方式 |
public static void ResetAbort() | 取消为当前线程请求的 Abort |
public static void SetData( LocalDataStoreSlot slot, Object data ) | 在当前正在运行的线程上为此线程的当前域在指定槽中设置数据。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段 |
public void Start() | 开始一个线程 |
public static void Sleep( int millisecondsTimeout ) | 让线程暂停一段时间 |
public static void SpinWait( int iterations ) | 导致线程等待由 iterations 参数定义的时间量 |
public static byte VolatileRead( ref byte address ) public static double VolatileRead( ref double address ) public static int VolatileRead( ref int address ) public static Object VolatileRead( ref Object address ) |
读取字段值。无论处理器的数目或处理器缓存的状态如何,该值都是由计算机的任何处理器写入的最新值。此方法有不同的重载形式。这里只给出了一些形式 |
public static void VolatileWrite( ref byte address, byte value ) public static void VolatileWrite( ref double address, double value ) public static void VolatileWrite( ref int address, int value ) public static void VolatileWrite( ref Object address, Object value ) |
立即向字段写入一个值,以使该值对计算机中的所有处理器都可见。此方法有不同的重载形式。这里只给出了一些形式 |
public static bool Yield() | 导致调用线程执行准备好在当前处理器上运行的另一个线程。由操作系统选择要执行的线程 |
1.4 创建线程
1.4.1 System.Threading.Thread
线程是通过扩展 Thread 类创建的。扩展的 Thread 类调用 Start() 方法来开始子线程的执行。
1.4.1.1 不带参数处理
下面的程序演示了这个概念:
using System;
using System.Threading;
namespace MultithreadingApplication
{
class ThreadCreationProgram
{
public static void CallToChildThread()
{
Console.WriteLine("Child thread starts");
}
static void Main(string[] args)
{
Console.WriteLine("In Main: Creating the Child thread");
ThreadStart childref = new ThreadStart(CallToChildThread);
Thread childThread = new Thread(childref);
childThread.Start();
Console.ReadKey();
}
}
}
结果:
In Main: Creating the Child thread
Child thread starts
1.4.1.2 带参数处理
线程函数通过委托传递,可以不带参数,也可以带参数(只能有一个参数
),可以用一个类或结构体封装参数
using System;
using System.Threading;
namespace Test
{
class Program
{
static void Main(string[] args)
{
Thread t1 = new Thread(new ThreadStart(TestMethod));
Thread t2 = new Thread(new ParameterizedThreadStart(TestMethod));
t1.IsBackground = true;
t2.IsBackground = true;
t1.Start();
t2.Start("hello");
Console.ReadKey();
}
public static void TestMethod()
{
Console.WriteLine("不带参数的线程函数");
}
public static void TestMethod(object data)
{
string datastr = data as string;
Console.WriteLine("带参数的线程函数,参数为:{0}", datastr);
}
}
}
1.4.1.3 不用new ThreadStart
c#2.0 后可以直接传入方法而不用写 new ThreadStart
,但是需要注意,如果 CallToChildThread 方法没有重载
,可以直接用下面的简化写法,如果有重载就需要先用 new ThreadStart
创建线程入口,不然编辑器会报“方法或属性调用不明确”错误
using System.Threading.Tasks;
using System.Threading;
namespace ConsoleApp
{
class Program
{
public static void CallToChildThread()
{
Console.WriteLine("Child thread starts");
}
static void Main(string[] args)
{
//ThreadStart childref = new ThreadStart(CallToChildThread);
Console.WriteLine("In Main: Creating the Child thread");
Thread childThread = new Thread(CallToChildThread);
childThread.Start();
Console.ReadKey();
}
}
}
1.4.2 ThreadPool
ThreadPool 由 .Net
自己管理, 只需要把需要处理的方法写好, 然后交个.Net Framework, 后续只要方法执行完毕, 则自动退出。ThreadPool 提供了一种简单而高效的方式来管理线程池,从而可以方便地创建和管理多线程应用程序。使用线程池可以显著降低创建和销毁线程的开销,并且有助于更好地管理系统资源。
using System;
using System.Threading;
class Program
{
// 工作方法,线程池中的线程将执行这个方法。
static void ThreadPoolCallback(object state)
{
// 将传递的状态信息转换为字符串并打印。
Console.WriteLine("Thread ID: {0}, State: {1}", Thread.CurrentThread.ManagedThreadId, state);
// 模拟一些工作负载。
Thread.Sleep(2000); // 休眠2秒
// 打印完成信息。
Console.WriteLine("Thread ID: {0} completed.", Thread.CurrentThread.ManagedThreadId);
}
static void Main()
{
// 要排队到线程池中的任务数量。
int numberOfTasks = 5;
// 排队任务到线程池中。
for (int i = 0; i < numberOfTasks; i++)
{
// 使用 ThreadPool.QueueUserWorkItem 方法将任务排队到线程池中。
// 这个方法接受一个 WaitCallback 委托和一个状态对象作为参数。
ThreadPool.QueueUserWorkItem(ThreadPoolCallback, i);
}
// 防止主线程在所有任务完成之前退出。
Console.WriteLine("Main thread waiting for ThreadPool tasks to complete...");
Thread.Sleep(15000); // 休眠15秒以等待所有任务完成
Console.WriteLine("Main thread exiting.");
}
}
1.4.3 System.Threading.Tasks.Task
1.4.3.1 Task与ThreadPool区别
Task
和 ThreadPool
在 C#
中都是用于实现多线程和并行编程的机制,但它们在使用方式和效率上有所不同。
- 创建方式的区别
ThreadPool
:
ThreadPool
是一个线程池实现,它重用线程以减少创建和销毁线程的开销。
使用ThreadPool
时,通常通过调用ThreadPool.QueueUserWorkItem
方法将任务排队到线程池中。
线程池中的线程由系统自动管理,开发者不需要手动创建和销毁线程。Task
:
Task
是基于ThreadPool
的高层抽象,旨在简化并行编程。
Task
提供了丰富的 API,可以方便地执行异步操作,并等待其完成。
使用Task
时,通常通过Task.Run
方法或Task.Factory.StartNew
方法来创建并启动任务。
Task 的实际执行依赖于任务调度器(TaskScheduler),默认情况下,任务调度器会使用线程池(ThreadPool)。
- 效率的比较
ThreadPool
:
适用于执行大量短暂任务的场景。
减少了线程创建和销毁的开销,因为线程会被重用来执行多个任务。
线程池会根据需求调整线程数量,但这个调整有一个上限,依赖于系统的最大线程数。Task
:
建立在ThreadPool
上,可以更好地利用ThreadPool
的线程池机制。
提供了更高级的抽象和 API,使得并行编程更加简单和直观。
支持异步编程模式(async/await
),使得编写异步代码更加简洁。
在处理大量并行任务时,Task 的性能通常优于直接操作 ThreadPool
1.4.3.2 Task 的使用方式
- 执行异步操作:
使用Task.Run
方法将工作提交给线程池来异步执行。
可以使用await
关键字等待任务完成并获取结果。 - Async/Await:
在异步方法中结合async
和await
关键字来编写流畅的异步代码。
异步方法通常返回一个Task
或Task<T>
类型的对象。 - 创建和启动 Task:
除了Task.Run
方法外,还可以直接实例化 Task 并通过.Start
方法手动启动。
也可以使用TaskFactory
创建并开始新任务。 - 取消任务:
可以通过传递CancellationToken
到Task.Run
或Task.Factory.StartNew
来支持任务取消。 - 等待任务完成:
使用Task.Wait
方法可以同步等待任务完成(但通常不推荐,因为它可能导致当前线程阻塞)。
使用Task.WhenAll
等待多个任务同时完成。
使用Task.WhenAny
等待任意一个任务完成
1.4.3.3 Start 和 Wait
什么时候需要调用 Start
调用
Start
是 手动启动任务 的一种方式,仅适用于通过Task 构造函数
创建的任务。这类任务初始状态是未启动
的,必须显式调用Start
方法将其加入线程池。
什么时候不需要调用 Start?
通过
Task.Run
或Task.Factory.StartNew
创建的任务会自动开始执行
,无需调用Start
,而且尝试调用Start
会导致异常。
这时候就需要Task.Wait
,阻塞调用线程,直到 Task 完成执行。如果任务已经完成,则立即返回。如果任务尚未开始或正在运行,则阻塞调用线程,直到任务完成。
区别与使用场景
方法 | 作用 | 适用场景 |
---|---|---|
Start | 显式启动任务 | 需要手动控制任务启动时间,适用于通过构造函数创建的任务 |
Wait | 阻塞调用线程,等待任务完成 | 当需要确保任务完成后再继续后续代码时使用 |
Start使用场景
创建方式 | 是否需要调用 Start | 推荐做法 |
---|---|---|
new Task(...) 构造函数 | 是 | 必须调用 Start 手动启动任务 |
Task.Run | 否 | 不需要调用 Start,直接创建并运行 |
Task.Factory.StartNew | 否 | 不需要调用 Start,直接创建并运行 |
在现代 C# 开发中,通常建议使用 Task.Run
或 async/await
处理异步任务,而不是通过显式调用 Start 来管理任务
1.4.3.4 示例
基本任务创建与执行
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
// 创建一个简单的任务,该任务会打印一条消息
Task task = Task.Run(() =>
{
Console.WriteLine("Task is executing...");
});
// 等待任务完成
task.Wait();
Console.WriteLine("Task is completed.");
}
}
返回结果的任务
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
// 创建一个返回整数结果的任务
Task<int> task = Task.Run(() =>
{
// 模拟一些计算或处理
return 42;
});
// 等待并获取任务结果
int result = task.Result;
Console.WriteLine("Task result: " + result);
}
}
并行执行多个任务
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
// 创建三个任务
Task task1 = Task.Run(() =>
{
Console.WriteLine("Task 1 is executing...");
});
Task<int> task2 = Task.Run(() =>
{
Console.WriteLine("Task 2 is executing...");
return 42;
});
Task task3 = Task.Run(() =>
{
Console.WriteLine("Task 3 is executing...");
});
// 等待所有任务完成
Task.WhenAll(task1, task2, task3).Wait();
Console.WriteLine("All tasks are completed.");
}
}
使用 ContinueWith 创建延续任务
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
// 创建一个任务
Task<int> task = Task.Run(() =>
{
// 模拟一些计算或处理
return 42;
});
// 创建一个在任务完成后执行的延续任务
task.ContinueWith(t =>
{
// 获取任务结果并打印
int result = t.Result;
Console.WriteLine("Continuation task: Task result is " + result);
});
// 为了演示效果,这里等待主任务完成(实际使用中可能不需要这样做)
task.Wait();
}
}
1.4.4 async/await
1.4.4.1 解释分析
在 C# 中,async
和 await
是用于异步编程的关键字
async
:标记一个方法为异步方法,使用时在方法返回类型前加上async
关键字
async
并不表示方法本身是异步的,仅仅表示方法可以包含异步
操作,并不会自动使方法异步执行。方法中的代码仍然会按顺序执行,直到遇到第一个await
await
:在异步方法中等待另一个异步操作完成。await
表达式会暂停当前方法的执行,直到被等待的任务完成,await
会暂停执行,但不阻塞线程,await
关键字会暂停当前方法的执行
。
await
会将方法的剩余部分注册为回调
,以便在等待的操作完成后继续执行,不会阻塞当前线程
在等待期间,线程可以执行其他任务,await
方法完成后将自动恢复执行。
注意
:await
必须放在async
方法内使用
1.4.4.2 异步方法的异常处理
异步方法中的异常会被包装在 Task
对象中。要捕获异常,可以使用 try-catch 语句,并在 await
表达式中捕获,避免 async void
方法<除非是事件处理器,不推荐使用 async void,因为它不允许捕获异常,且调用者无法等待其完成。应优先使用 Task
或 Task<T>
。
public async Task MyMethod()
{
try
{
await SomeAsyncMethod();
}
catch (Exception ex)
{
Console.WriteLine($"捕获异常: {ex.Message}");
}
}
1.4.4.3 await 返回
await
只能用于 Task
或 Task<T>
,或实现了 GetAwaiter
方法的类型。
public class AsyncExample
{
public async Task<string> FetchDataAsync()
{
// 模拟一个耗时操作,例如网络请求
await Task.Delay(2000);
return "异步数据";
}
public async Task RunExampleAsync()
{
try
{
string data = await FetchDataAsync();
Console.WriteLine(data);
}
catch (Exception ex)
{
Console.WriteLine($"捕获异常: {ex.Message}");
}
}
}
1.4.4.4 示例分析
using System;
using System.Threading.Tasks;
public class Program
{
public static async Task Main(string[] args)
{
Console.WriteLine("开始获取数据...");
// 调用异步方法,并等待其结果
string result = await GetDataAsync();
Console.WriteLine($"数据获取完成: {result}");
}
// 定义一个异步方法,用 async 标记
public static async Task<string> GetDataAsync()
{
// 使用 Task.Delay 来模拟一个耗时操作,例如网络请求
await Task.Delay(3000); // 等待3秒
return "这里是获取到的数据";
}
}
结果
开始获取数据...
(等待 3 秒)
数据获取完成: 这里是获取到的数据
详细解释:
async
关键字:GetDataAsync
方法被标记为async
,表示它是一个异步方法,可以使用await
关键字来等待异步任务完成。await
关键字:在GetDataAsync
中,使用 await Task.Delay(3000) 来模拟一个耗时的操作。await
关键字会暂停方法的执行,直到 Task.Delay 完成。- 返回类型:
async
方法的返回类型通常是Task
或Task<T>
(泛型形式)。在本例中,GetDataAsync 返回Task<string>
,表示返回的是一个包含 string 结果的任务。
1.4.5 System.Threading.Tasks.Parallel
System.Threading.Tasks.Parallel
类并行执行,不是真正的多线程,但用于并行处理数据
这个类是 C# 中用于并行编程
的一个重要工具,并行地执行循环或操作,从而充分利用多核处理器的性能。
Parallel
类利用线程池(ThreadPool
)来管理线程,并自动将任务分配给可用的线程。这意味着开发者不需要显式地创建和管理线程,而是将精力集中在任务的定义和并行执行上。尽管Parallel
类并不直接创建线程,但它确实利用了多线程来实现并行处理。线程池中的线程是实际执行任务的实体,而Parallel
类则提供了更高层次的抽象,使开发者能够更容易地实现并行处理。
因此,可以说Parallel
类提供了一种并行处理数据的方式,这种方式基于多线程,但又不完全等同于多线程编程。它更多地是一种任务并行化的编程模型,允许开发者以更简单、更直观的方式实现并行处理。
Parallel
类是一个静态类,它提供了几个静态方法用于并行执行代码,主要包括 Parallel.For
、Parallel.ForEach
和 Parallel.Invoke
:
Parallel.For
:用于并行执行 for 循环,它会自动将循环的迭代分配给多个线程,并不直接提供控制迭代间隔的功能
csharp
Parallel.For(0, 10, i =>
{
Console.WriteLine($"Iteration {i} is running on thread {Thread.CurrentThread.ManagedThreadId}");
});
Parallel.ForEach
:用于并行执行foreach
循环。它会自动将集合中的元素分配给多个线程。
var numbers = new List<int> { 1, 2, 3, 4, 5 };
Parallel.ForEach(numbers, number =>
{
Console.WriteLine($"Processing {number} on thread {Thread.CurrentThread.ManagedThreadId}");
});
Parallel.Invoke
:行执行多个操作。你可以传递多个要并行执行的 Action 或 Func 委托。
Parallel.Invoke(
() => { /* Task 1 */ },
() => { /* Task 2 */ },
() => { /* Task 3 */ }
);