C# 多线程






4.51/5 (12投票s)
C# 多线程概述
引言
正如我们所知,C# 中的任何代码块都在一个称为线程的进程中执行,这是程序的执行路径。通常,应用程序在单个线程上运行。然而,多线程有助于在多个线程中运行应用程序。为了在不同线程之间共享进程的执行,我们必须使用多线程。在本文中,我将介绍 C# 中的多线程、Thread
类、创建线程、ThreadStart 委托
、带参数的线程、线程同步、线程监视器、AutoResetEvent
类及其用途,以及互斥体和信号量。对于我们的示例,我将使用一个简单的控制台应用程序。
背景
线程是处理多线程的核心概念。当程序执行时,每个线程都会获得一定的 CPU 时间片。为了同时执行多个线程,我们必须使用多线程。例如,在将大文件从客户端传输到服务器时,如果没有多线程,我们将阻塞图形界面,但使用线程,我们可以将文件发送或其他资源密集型任务分离到单独的流中。由此可见,如今的客户端-服务器应用程序离不开多线程。
要使用多线程,我们需要 System.Threading
命名空间。它定义了一个表示单独线程的类——Thread
类。该类主要属性如下:
ExecutionContext
:允许您获取线程正在执行的上下文。IsAlive
:指示线程当前是否正在运行。IsBackground
:指示线程是否在后台运行。Name
:包含线程的名称。ManagedThreadId
:返回当前线程的数字 ID。
Priority:存储线程的优先级——ThreadPriority 枚举
的值。
Lowest
BelowNormal
正常
AboveNormal
Highest
默认情况下,线程具有 Normal
优先级。但是,我们可以在程序运行时更改优先级。例如,通过将优先级设置为 Highest
来提高线程的重要性。公共语言运行时会读取和解析优先级值,并根据这些值为此线程分配一定的 CPU 时间。
ThreadState
返回线程的状态——ThreadState 枚举
的一个值。
Aborted
:线程已停止,但尚未完全终止。AbortRequested
:已对线程调用 Abort,但线程尚未终止。Background
:线程正在后台运行。Running
:线程正在运行(未暂停)。Stopped
:线程已终止。StopRequested
:线程已收到停止请求。Suspended
:线程已被挂起。SuspendRequested
:线程已收到挂起请求。Unstarted
:线程尚未启动。WaitSleepJoin
:线程由于Sleep
或Join
方法而阻塞。
例如,即使在 start
方法执行之前,线程的状态也是 Unstarted
。但是,如果我们启动线程,其状态将变为 Running
。此外,通过调用 sleep 方法,状态和情况将变为 WaitSleepJoin
。这意味着在任何线程的操作过程中,其状态都可能通过方法而改变。
Thread
类的 static
属性 CurrentThread
允许您获取当前线程。如前所述,C# 中至少有一个线程,Main
方法在该线程中执行。
让我们看一个代码示例。
using System;
using System.Threading;
namespace Threading
{
class Program
{
static void Main(string[] args)
{
//Creating instance of Thread
Thread currentThread = Thread.CurrentThread;
//Get name of Thread
Console.WriteLine($"Thread: {currentThread.Name}");
currentThread.Name = "Main";
Console.WriteLine($"Thread name: {currentThread.Name}");
Console.WriteLine($"Thread Id: {currentThread.ManagedThreadId}");
Console.WriteLine($"Thread is Alive? : {currentThread.IsAlive}");
Console.WriteLine($"Priority of Thread: {currentThread.Priority}");
Console.WriteLine($"Status of Thread: {currentThread.ThreadState}");
Console.WriteLine($"IsBackground: {currentThread.IsBackground}");
Console.ReadKey();
}
}
}
结果如图 1 所示。
从示例中可以看出,在第一种情况下,我们将 Name
属性获取为一个空 string
。这种情况发生是因为 Thread
对象的 Name
属性默认未设置。此外,Thread
类定义了许多用于管理线程的方法。主要方法如下:
static
GetDomain
方法返回对应用程序域的引用。static
方法GetDomainID
返回当前线程运行的应用程序域的 ID。static
方法Sleep
将线程暂停指定的毫秒数。Interrupt
方法中断处于WaitSleepJoin
状态的线程。Join
方法会阻塞调用它的线程的执行,直到调用此方法的线程结束。Start
方法启动一个线程。
例如,让我们使用 Sleep
方法来设置应用程序的执行延迟。
for (int i = 0; i < 50; i++)
{
Thread.Sleep(1000); // here, we have delay execution by 1000 milliseconds
Console.WriteLine(i);
}
结果如图 2 所示。
结果尤其显得不完整,因为我们在循环迭代之间有 1000 毫秒的延迟。
创建线程
如前所述,C# 允许您运行一个应用程序,其中多个线程将同时执行。否则,这篇文章就不会存在了。Thread
类的一个构造函数用于创建线程。
Thread(ThreadStart)
:以ThreadStart 委托
对象作为参数,该对象表示要在线程上执行的操作。Thread(ThreadStart, Int32)
:除了ThreadStart 委托
外,它还接受一个数值,用于设置为此线程分配的堆栈大小。Thread(ParameterizedThreadStart)
:以ParameterizedThreadStart 委托
对象作为参数,该对象表示要在线程上执行的操作。Thread(ParameterizedThreadStart, Int32)
:与ParameterizedThreadStart 委托
一起,它接受一个数值,用于设置此线程的堆栈大小。
ThreadStart 委托
此委托表示一个不带参数且不返回值的操作。
public delegate void ThreadStart();
让我们看看代码。
// Create a new Thread
Thread Thread1 = new Thread(Print);
Thread Thread2 = new Thread(new ThreadStart(Print));
Thread Thread3 = new Thread(() => Console.WriteLine("Hello from thread3"));
Thread1.Start(); // Thread1 starts
Thread2.Start(); // Thread1 starts
Thread3.Start(); // Thread1 starts
void Print()
{
Console.WriteLine("Threads");
}
此代码向您展示了创建新 Thread
的不同方法,但结果相同。此外,程序执行的结果显示在图 3 中,但可能有所不同,因为线程是同时运行的。
让我们看另一个例子。
// Create a new Thread
Thread MainThread = new Thread(Print);
// Run thread
MainThread.Start();
// Actions that we make in the Main Thread
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Main Thread: {i}");
//Pause thread
Thread.Sleep(300);
}
// Actions from second thread
void Print()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Second Thread: {i}");
Thread.Sleep(400);
}
}
结果如图 4 所示。
在主线程(我们程序的 Main
方法)中,我们创建并启动一个新线程,在该线程中执行 Print
方法,同时,我们在此处执行类似的操作——我们以 300 毫秒的延迟将数字从 0 到 9 打印到控制台。因此,在我们的程序中,主线程(由 Main
方法表示)和执行 Print
方法的第二个线程将同时运行。一旦所有线程完成,程序将完成其执行。这就是我们可以同时运行更多线程的方式。
ParameterizedThreadStart 和带参数的线程
到目前为止,我们已经研究了如何在没有参数的情况下运行线程。但是,如果我们想向线程传递参数怎么办?为此,可以使用 ParameterizedThreadStart 委托
,它被传递给 Thread
类的构造函数。
public delegate void ParameterizedThreadStart(object? obj);
ParameterizedThreadStart
与 ThreadStart
非常相似,让我们看一个例子。
// Create new Threads
Thread Thread1 = new Thread(new ParameterizedThreadStart(Print));
Thread Thread2 = new Thread(Print);
Thread Thread3 = new Thread(message => Console.WriteLine(message));
// Run the threads
Thread1.Start("Hi");
Thread2.Start("Hello");
Thread3.Start("Hello world");
void Print(object ? message)
{
Console.WriteLine(message);
}
结果如下面的图 5 所示。
创建线程时,Thread
类的构造函数将传递 delegate object
ParameterizedThreadStart new Thread(new ParameterizedThreadStart(Print))
,或者直接传递符合此委托的方法 (new Thread(Print))
,包括以 lambda 表达式的形式 (new Thread(message => Console.WriteLine(message)))
。
然后,当线程启动时,Start()
方法将传递给 Print
方法的参数的值。但是,我们只能在第二个线程中运行一个以 object?
类型作为唯一参数的方法。我们可以通过装箱来解决此限制。此外,我们可以传递多个不同类型甚至自身类型的参数。让我们看另一个例子。
static void Main(string[] args)
{
Student student = new Student() { Id=1,Name="John"};
Thread Thread = new Thread(Print);
myThread.Start(student);
void Print(object? obj)
{
if (obj is Student person)
{
Console.WriteLine($"Id = {student.Id}");
Console.WriteLine($"Name = {student.Name}");
}
}
Console.ReadKey();
}
class Student
{
public int Id { get; set; }
public string Name { get; set; }
}
结果如图 6 所示。
但是,我强烈不推荐这种方法,因为 Thread.Start
方法不是类型安全的,也就是说,我们可以传递任何类型给它,然后我们必须将传递的对象转换为我们需要的类型。我建议将所有使用的方法和变量声明在一个特殊的类中,并在主程序中通过 ThreadStart
启动线程。例如:
static void Main(string[] args)
{
Student student = new Student() { Id=1,Name="John"};
Thread myThread = new Thread(student.Print);
myThread.Start();
Console.ReadKey();
}
class Student
{
public int Id { get; set; }
public string Name { get; set; }
public void Print()
{
Console.WriteLine($"Id = {Id}");
Console.WriteLine($"Name = {Name}");
}
}
}
在本节中,我们研究了带参数的线程和 ParameterizedThreadStart
。
线程同步
在前面的部分中,我研究了没有线程同步的示例。结果,我们可能会得到不同的程序执行顺序。在实际项目中,线程使用一些对整个程序通用的共享资源并不少见。这些可以是共享变量、文件和其他资源。状态问题的解决方案是同步线程并限制对共享资源的访问,直到它们被线程使用。为此,使用了 lock
语句,它定义了一个代码块,在该代码块内,所有代码都被阻塞,并且在当前线程终止之前,其他线程无法访问。其余线程被放入等待队列,直到当前线程释放给定的代码块。请看示例(仅在 C#8 或更高版本中可用):
static void Main(string[] args)
{
int i = 0;
object locker = new();
//
for (int x = 1; x <= 5; x++)
{
Thread Thread = new(Print);
Thread.Name = $"Thread {x}";
Thread.Start();
}
void Print()
{
lock (locker)
{
i = 1;
for (int x = 1; x <= 5; x++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {i}");
i++;
Thread.Sleep(100);
}
}
}
Console.ReadKey();
}
结果如图 7 所示。
监视器
在上一个章节中,我们研究了用于线程同步的 gloss 操作符。然而,这并非同步线程的唯一方法。我们还可以使用监视器,它们由 System.Threading.Monitor
类表示。它具有以下方法:
void Enter(object obj)
:获取对作为参数传递的对象的独占所有权。void Enter(object obj, bool acquiredLock)
:此外,它还接受第二个参数——一个布尔值,指示是否已从第一个参数获取了对象的拥有权。void Exit(object obj)
:释放先前捕获的object
。bool IsEntered(object obj)
:如果监视器已进入obj
,则返回true
。void Pulse(object obj)
:通知等待队列中的一个线程,当前线程已释放object obj
。void PulseAll(object obj)
:通知等待队列中的所有线程,当前线程已释放obj
。之后,等待队列中的一个线程将捕获obj object
。bool TryEnter(object obj)
:尝试获取object obj
。如果成功获得对象的所有权,则返回true
。bool Wait(object obj)
:释放对象的锁,并将线程放入对象的等待队列。object
的就绪队列中的下一个线程将锁定该对象。并且所有调用了Wait
方法的线程都将留在等待队列中,直到它们收到由锁的拥有者发送的Monitor.Pulse
或Monitor.PulseAll
方法的信号。
使用监视器的语法封装在 lock 语法中。基于前面的示例,让我们使用监视器重写代码。
int i = 0;
object locker = new();
//
for (int x = 1; x <= 5; x++)
{
Thread Thread = new(Print);
Thread.Name = $"Thread {x}";
Thread.Start();
}
void Print()
{
bool Lock = false;
try
{
Monitor.Enter(locker, ref Lock);
i = 1;
for (int x = 1; i < 6; x++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {i}");
i++;
Thread.Sleep(100);
}
}
finally
{
if (Lock) Monitor.Exit(locker);
}
}
结果如图 8 所示。
lock
对象和一个 bool
值被传递给 Monitor.Enter
方法。bool
值指示 lock
的结果,如果为 true
,则 lock
成功完成。然后,此方法以与 lock
语句相同的方式锁定 locker object
。如果 lock
成功,并且通过 try...finally
块中的 Monitor.Exit
方法使其可供其他线程使用。
在本节中,我们研究了监视器的工作原理。
AutoResetEvent 类
在上一篇文章中,我们研究了监视器的工作原理。然而,还有一个 AutoResetEvent
类也用于线程同步。该类表示一个线程同步事件,它允许您在收到信号时将该事件对象从已发出信号的状态切换到未发出信号的状态。
为了管理同步,AutoResetEvent
类提供了一系列方法:
Reset()
:通过阻塞线程将对象的已发出信号状态设置为未发出信号状态。Set()
:将对象的已发出信号状态设置为已发出信号状态,允许一个或多个等待的线程继续运行。WaitOne()
:将状态设置为未发出信号,并阻止当前线程,直到当前AutoResetEvent object
收到信号。
同步事件可以处于已发出信号或未发出信号的状态。如果事件状态为未发出信号,则调用 WaitOne
方法的线程将阻塞,直到事件状态变为已发出信号。相反,Set
方法将事件的状态设置为已发出信号。
让我们举一个使用 lock
方法的例子,并用 AutoResetEvent
替换它。
int i = 0;
AutoResetEvent SomeEvent = new AutoResetEvent(true);
for (int x = 1; x <= 5; x++)
{
Thread Thread = new(Print);
Thread.Name = $"Thread {x}";
Thread.Start();
}
void Print()
{
SomeEvent.WaitOne(); // ожидаем сигнала
i = 1;
for (int x = 1; i <= 5; x++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {i}");
i++;
Thread.Sleep(100);
}
SomeEvent.Set();
}
结果如图 9 所示。
首先,我们创建了一个 AutoResetEvent
类型的变量。通过将 true
传递给构造函数,我们表明正在创建的对象将最初处于已发出信号的状态。
第二,当线程开始运行时,调用 SomeEvent.WaitOne()
会触发。然后 WaitOne
方法指定当前线程被置于等待状态,直到 waitHandler object
被信号。这样,所有线程都被转移到等待状态。
第三,工作完成后,调用 waitHandler.Set
方法,该方法通知所有等待的线程 waitHandler
对象再次处于已发出信号的状态,并且其中一个线程“捕获”该对象,将其转移到未发出信号的状态并执行其代码。其余线程再次等待。
由于我们在 AutoResetEvent
构造函数中指定对象最初处于已发出信号的状态,因此队列中的第一个线程获取该对象并开始执行其代码。
但是,如果我们写 AutoResetEvent SomeEvent = new AutoResetEvent(false)
,那么对象最初将处于未发出信号的状态,并且由于所有线程都通过 waitHandler.WaitOne()
方法阻塞,直到等待信号,那么程序将简单地阻塞,不会执行任何操作。
如果我们使用多个 AutoResetEvent
对象,那么我们可以使用 static WaitAll
和 WaitAny
方法来跟踪这些对象的状态,这些方法接受一个 WaitHandle
类(AutoResetEvent
的基类)对象的数组作为参数。
互斥体和信号量
除了前面文章中讨论的线程同步方法之外,还有互斥体和信号量。
Mutex
类也位于 System.Threading
命名空间中。再次,让我们用 Mutex
类重写我们使用 lock
方法的示例。
int i = 0;
Mutex mutex = new();
for (int x = 1; x <= 5; x++)
{
Thread Thread = new(Print);
Thread.Name = $"Thread {x}";
Thread.Start();
}
void Print()
{
mutex.WaitOne(); // Wait for mutex obj
i = 1;
for (int x = 1; i <= 5; x++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {i}");
i++;
Thread.Sleep(200);
}
mutex.ReleaseMutex();
}
结果如图 10 所示。
首先,我们使用 Mutex mutex = new()
创建一个互斥体。
接下来,主要的同步工作由 WaitOne()
和 ReleaseMutex()
方法完成。mutex.WaitOne()
方法会暂停线程的执行,直到获得 mutex mutex
。
之后,最初,mutex
是空闲的,所以其中一个线程会获得它。
接下来,完成所有工作后,当不再需要 mutex
时,线程使用 mutex.ReleaseMutex()
方法释放它。然后 mutex
被等待的线程之一获取。
最后,当执行到达 mutex.WaitOne()
的调用时,线程将等待直到 mutex
被释放。并在获得它之后,它将继续执行其工作。
信号量是 .NET 平台为我们提供的另一种用于管理同步的工具。信号量允许您限制可以访问某些资源的线程数量。在 .NET 中,信号量由 Semaphore
类表示。
要创建信号量,可以使用 Semaphore
类的一个构造函数。
Semaphore (int initialCount, int maximumCount)
:initialCount
参数指定初始线程数,maximumCount
是可以访问共享资源的线程的最大数量。Semaphore(int initialCount, int maximumCount, string? name)
:可选地指定信号量的名称。Semaphore(int initialCount, int maximumCount, string? name, out bool createdNew)
:最后一个参数是createdNew
,当为true
时,表示成功创建了新的信号量。如果此参数为false
,则具有指定名称的信号量已存在。
为了与线程协同工作,Semaphore
类有两个主要方法:
WaitOne()
:等待信号量中的可用空间。release()
:释放信号量中的空间。
考虑一个例子,我们有一些学生可以通过门户网站上的课程观看材料。在我们的例子中,门户网站上不能超过五名学生。虽然这不是一个非常真实的实践例子,但它适合我们用来考虑信号量的工作。
class Program
{
static void Main(string[] args)
{
for (int i = 1; i <= 5; i++)
{
Student student = new Student(i);
}
Console.ReadKey();
}
}
class Student
{
// Create semahore
static Semaphore semahore = new Semaphore(5, 5);
Thread Thread;
int count = 5;// counter
public Student(int x)
{
Thread = new Thread(Join);
Thread.Name = $"Student {x}";
Thread.Start();
}
public void Join()
{
while (count > 0)
{
semahore.WaitOne(); // wait
Console.WriteLine($"{Thread.CurrentThread.Name} enters in portal");
Console.WriteLine($"{Thread.CurrentThread.Name} doing something");
Thread.Sleep(1000);
Console.WriteLine($"{Thread.CurrentThread.Name} lives portal");
semahore.Release(); // clean place
count--;
Thread.Sleep(5000);
}
}
}
结果是:
Student 3 enters in portal
Student 1 enters in portal
Student 1 doing something
Student 5 enters in portal
Student 5 doing something
Student 4 enters in portal
Student 2 enters in portal
Student 3 doing something
Student 4 doing something
Student 2 doing something
Student 5 lives portal
Student 1 lives portal
Student 2 lives portal
Student 4 lives portal
Student 3 lives portal
Student 5 enters in portal
Student 1 enters in portal
Student 5 doing something
Student 1 doing something
Student 3 enters in portal
Student 4 enters in portal
Student 2 enters in portal
Student 4 doing something
Student 2 doing something
Student 3 doing something
Student 5 lives portal
Student 1 lives portal
Student 4 lives portal
Student 2 lives portal
Student 3 lives portal
Student 1 enters in portal
Student 5 enters in portal
Student 5 doing something
Student 1 doing something
Student 4 enters in portal
Student 2 enters in portal
Student 4 doing something
Student 2 doing something
Student 3 enters in portal
Student 3 doing something
Student 1 lives portal
Student 5 lives portal
Student 4 lives portal
Student 2 lives portal
Student 3 lives portal
Student 1 enters in portal
Student 1 doing something
Student 5 enters in portal
Student 5 doing something
Student 2 enters in portal
Student 2 doing something
Student 4 enters in portal
Student 4 doing something
Student 3 enters in portal
Student 3 doing something
Student 5 lives portal
Student 1 lives portal
Student 4 lives portal
Student 2 lives portal
Student 3 lives portal
Student 1 enters in portal
Student 1 doing something
Student 5 enters in portal
Student 5 doing something
Student 2 enters in portal
Student 2 doing something
Student 4 enters in portal
Student 4 doing something
Student 3 enters in portal
Student 3 doing something
Student 1 lives portal
Student 5 lives portal
Student 2 lives portal
Student 4 lives portal
Student 3 lives portal
让我们看看代码。
首先,在此程序中,读者由 Student
类表示。它通过 Thread
变量封装了所有与线程相关的功能。semaphore
本身被定义为 static
变量 sem。
static Semaphore semahore = new Semaphore(5, 5);
其次,它的构造函数接受两个参数:第一个指定 semaphore
最初可供多少个对象使用,第二个参数指定 semaphore
将使用的最大对象数量。在这种情况下,我们一次只有三个读者可以在图书馆,所以最大数量是 5
。
接下来,主要功能集中在 Read
方法中,该方法在线程中执行。首先,使用 semahore.WaitOne()
方法等待接收信号量。在 semaphore
中的空间变为空闲后,该线程填充空闲空间并开始执行所有后续操作。阅读完成后,我们使用 semahore.Release()
方法释放 semaphore
。
之后,semaphore
中会腾出一个位置,由另一个线程填充。
在本节中,我们研究了互斥体和信号量。
结论
总之,我们研究了 C# 中的多线程、Thread
类、创建线程、ThreadStart 委托
、带参数的线程、线程同步、线程监视器、AutoResetEvent
类、互斥体和信号量。
历史
- 2022年3月2日:初始版本