C#/.Net 中的多线程介绍和最佳实践

I/ 引言

计算机中的线程 CPU

调度程序和时间切片

进程和线程

并发和并行性

异步与多线程

在 C 中使用多线程的好处#

II 线程 C 语言#

线程生命周期

创建、启动和暂停线程

加入

中止

中断

线程取消:停止线程的更好方法

III/ 线程问题

死锁和争用条件

使用 Join 和 Locks

AutoResetEvent

线程性能问题防止争用条件和死锁

IV/ 线程池

V/ 同步机制

互斥

信号量

监视器(锁定)

VI/ 线程管理

前台线程与后台线程

线程上下文

将数据传递到线程

线程优先级

线程本地存储

调试线程

VII/ 在 .Net 中使用线程的推荐方法

I/ 引言

计算机 CPU 中的线程

在了解线程和并行性之前,重要的是要很好地掌握底层硬件的工作原理。

CPU(中央处理器)是计算机的大脑,负责执行运行应用程序所需的所有指令。

现代计算机通常有多个内核,每个内核都可以划分为逻辑处理器。

每个处理器又可以分为两个逻辑处理器

现在,每个逻辑内核都可以同时处理多个线程!例如,由于超线程技术,4 核处理器可以并行处理 8 个线程。

若要了解电脑有多少个内核和逻辑内核,请转到任务管理器>性能

或者,可以在应用程序中运行以下代码:

Console.WriteLine("Cores count: " + Environment.ProcessorCount);

调度程序和时间片

调度

在现实生活中,每个内核同时运行许多指令。

为了防止一个任务阻塞其他任务,它会为每个任务分配固定的时间。

以非常高的速度在所有任务之间切换,感觉所有任务都在同时运行!

在 Windows 中,称为调度程序的特殊程序确定处理器将在其中执行指令的顺序和时间范围(称为处理器时间片)。

它的工作是决定处理器使用指令的顺序和时间范围。

处理器时间片

这些时间范围也称为处理器时间片。

这些时间片是处理器处理特定指令的时间段。为了防止长指令阻塞整个计算机,每条指令都有一个特定的时间片。

进程和线程

现在让我们看看进程和线程之间的区别。

过程

  • 基本上是执行程序的实例

  • 有自己的内存空间和资源

  • 独立于其他进程运行

  • 可以包含一对多线程

一个典型的系统可能有数百个进程同时运行。

每个进程都充当线程的容器

当进程启动时,它会分配自己的内存和资源,而这些内存和资源又将在线程之间共享。

线

  • 进程中的执行单元

  • 每个都有自己的堆栈

  • 与同一进程中的其他线程共享堆内存

目前有 269 个进程和 3506 个线程在运行

在多线程进程中,线程共享堆内存。

多线程是程序执行多个线程的能力,允许有效利用系统资源。

并发性和并行性

并发

  • 当两个或多个任务可以在重叠的时间段内启动、运行和完成时

  • 通过在多个任务之间快速切换,使单个线程能够处理多个任务,从而产生同时执行的错觉

排比

  • 当两个或多个任务在不同线程上同时运行时

  • 由两个线程执行的两个任务

异步与多线程

同步

  • 每个任务必须在下一个任务开始之前完成。

  • 如果一项任务花费的时间过长,可能会导致效率低下,因此在任务完成之前冻结或阻止应用程序。

任务一个接一个地执行

异步(单线程)

  • 单个线程通过在多个任务之间切换来处理多个任务

  • 允许任务在不阻塞流程的情况下并发进行

这两个任务在同一线程中启动并同时进行

异步(多线程)

  • 多个线程同时处理不同的任务

  • 允许更快、更高效地完成这两项任务

两个线程允许两个任务独立且同时进行

在 C 中使用多线程的好处#

性能

第一个明显的优势是利用硬件功能,并通过并行执行任务来加快手头的任务。

反应

通过同时运行多个进程,当用户单击以检索数据时,即使线程正忙于获取数据,整个应用也会保持响应

可扩展性

通过对每个任务使用不同的线程同时处理越来越多的请求,可以处理越来越多的请求。

II/ C 中的线程#

如前所述,线程是 CPU 的最低工作单位。

C# 提供了一个易于使用的库来处理线程:Thread 类,它能够管理整个线程生命周期!

Bob Code 原创

线程生命周期

典型的线程会经历这些阶段

  • 已创建线程

  • 线程开始

  • Thread completes 方法

  • 线程自动结束

创建、启动和暂停线程

可以通过多种方式创建线程

// Create a new thread  
var thread = new Thread(new ThreadStart(Operation));  
  
// Or in a more concise way  
var thread = new Thread(Operation);  
  
// Or using a lambda  
var thread = new Thread(() => { Operation(); });

如您所见,线程必须具有要实例化的方法委托

// A thread needs an operation (method delegate) to be instantiated  
var thread = new Thread(Operation);  
  
// Operation to be completed is passed into the thread  
private void Operation()  
{  
    Console.WriteLine("Hello from thread");  
}

尽管线程已创建,但需要显式启动

// start the thread   
thread.Start();

一旦启动,它就会自动执行任务,并在完成后结束。

一旦结束,线程就无法重新启动

但是,可以使用 .睡眠法。

时间过后,它将自动重新启动。

// Pause and interrupt threads  
Thread.Sleep(2000); // takes milliseconds or a TimeSpan  
sleepingThread.Interrupt();

有几种停止线程的方法,每种方法都有自己的优点和缺点:

  • 加入

  • 流产

  • 中断

Thread.Join

Thread.Join 将"优雅地"停止线程,这意味着代码将等待线程停止。

Thread thread = new Thread(Work);  
thread.Start();  
  
// Waits until the thread stops "gracefully"  
thread.Join();  
  
Console.WriteLine("Thread has ended.");

也可以传递超时以避免无限期等待线程完成

Thread thread = new Thread(Work);  
thread.Start();  
  
// Waits until the thread stops or the timeout interval has elapsed  
bool didComplete = thread.Join(1000);

这基本上会让主头等待 1 秒,看看线程是否已经完成。

Join 的阻塞性质

Thread.Join 是一个阻塞调用,这意味着在线程停止执行或已过可选超时间隔之前,它不会返回。

这意味着主线程必须等待线程完成。

举例来说,您永远不应该从它自己的线程调用 Join

从当前线程对当前线程调用 Thread.Join 将导致应用程序变得无响应,因为当前线程将无限期地等待自身

Thread thread = Thread.CurrentThread;  
  
// This will cause a deadlock and make the application unresponsive  
// thread.Join(); // Avoid this  
  
Console.WriteLine("Avoid calling Join on the current thread.");

加入多线程环境

由于 Join 是一个阻塞调用,它违背了多线程(和并行)目标!

然而,当其他线程应该等待一个线程来操作一个对象时,它可能在竞争条件下很有用(稍后会详细介绍)。

终止

立即引发 ThreadAbortException,强制线程过早停止

但是,它可能会在不关闭流连接的情况下突然停止线程,从而引入内存或资源泄漏。

此外,尚不清楚线程中断时线程或作的对象处于什么状态。

这可能会导致死锁、资源或内存泄漏!

突然中止线程

但是,.Net Core 中不再支持中断,因此它将引发 PlatformNotSupportedException。

中断

仅当中断的线程调用 或 时,才会抛出 。

它可用于将线程从阻塞操作中分离出来,例如等待对代码同步区域的访问或在

        Thread thread = new Thread(Work);  
        thread.Start();  
  
        // Give the thread some time to start  
        Thread.Sleep(500);  
  
        // Interrupt the thread, causing a ThreadInterruptedException  
        thread.Interrupt();  
  
        // Wait for the thread to handle the interruption and finish  
        thread.Join();

Thread.Interrupt仅当线程处于阻塞状态时,才会中断线程。它本身不会中止第三方代码,除非该代码当前处于阻塞调用中,例如 .Thread.Sleep

虽然仍然受支持,但对于更可预测和更可管理的线程中断,通常首选使用协作取消。

线程取消:停止线程的更好方法

但是,自 .Net 5+ 以来,在与线程中止相关的问题之后,Microsoft 现在建议使用线程取消而不是Thread.AbortThread.Interrupt.

此方法避免了与突然终止线程相关的不可预测性和潜在的资源泄漏。

// Instantiate cancellation token  
CancellationTokenSource cts = new CancellationTokenSource();  
  
// Pass token to thread  
Thread thread = new Thread(() => Work(cts.Token));  
Thread.Start();  
  
// Simulate some other work in main thread  
Thread.Sleep(1000);  
  
// Cancel the thread work after 1 second  
cts.Cancel();  
  
// Wait for the thread to end gracefully  
thread.Join();

CancellationTokenSource:此类提供用于发出取消信号的机制。它创建一个可以传递给线程的。CancellationToken

在工作方法中传递取消令牌

static void Work(CancellationToken cancellationToken)  
    {  
        try  
        {  
            while (true)  
            {  
                // Check for cancellation request  
                cancellationToken.ThrowIfCancellationRequested();  
  
                // Simulate work  
                Thread.Sleep(500);  
                Console.WriteLine("Working...");  
            }  
        }  
        catch (OperationCanceledException)  
        {  
            Console.WriteLine("Cancellation requested, ending work.");  
        }  
        finally  
        {  
            Console.WriteLine("Cleanup code here.");  
        }  
    }  
}

ThrowIfCancellationRequested:此方法引发 if 已请求取消,允许线程正常退出。

使用CancellationToken的好处

  • 正常退出:线程可以在退出之前完成其当前工作并正确清理资源。

  • 可预测性:线程以受控方式退出,避免了突然终止的风险。

  • 合作取消:线程会定期检查令牌,看看它是否应该停止,从而允许采用更合作的线程管理方法。

使用是管理现代 .NET 应用程序中线程生命周期的首选方法,可确保线程可以可预测且安全地取消。

III/ 线程问题

死锁和争用条件

如前所述,每个线程都有自己的堆栈,但每个线程共享堆内存。

这意味着多个线程可以访问和修改一个共享值。

这可能导致所谓的争用条件,基本上是两个线程同时更改相同的值。

让我们看一个代码示例

// Shared variable  
public static int i = 0;  
  
public static void ExecuteWork()  
{  
    // thread executes loop  
    var t = new Thread(DoWork);  
    t.Start();  
    // another thread executes loop leading to race condition  
    DoWork();  
}  
// Two threads execute this method  
public static void DoWork()  
{  
    for(i = 0;i < 5; i++)  
    {  
        Console.WriteLine("*");  
    }  
}  
// Results in "******" => 6 * being printed instead of 5

另一个问题是死锁,当两个或多个线程被永久阻塞时,就会发生死锁,每个线程都在等待另一个线程释放资源。

当多个线程需要同一组资源并以不同的顺序获取它们时,可能会发生死锁。

使用 Join 和 Locks 防止争用条件和死锁

为了防止竞争条件,可以:

  • 让线程互相等待

  • 锁定线程

正如我们之前看到的,可以使用 Thead.Join 完成等待线程

// suspends the main thread  
// wait till thread is finished  
// resumes main thread   
thread.Join();  
  
// Also takes a TimeSpan or int Milleseconds  
bool Thread.Join(TimeSpan timeout);

另一种方法是使用 Thread.Lock 语句

它的作用基本上是"锁定"共享对象,以便只有执行线程才能访问它。

线程完成后,它会释放对象。

using System;  
using System.Threading;  
  
class Program  
{  
// these objects are shared   
    private static readonly object lock1 = new object();  
    private static readonly object lock2 = new object();  
  
    public static void Main()  
    {  
        // Thread 1  
        var t1 = new Thread(Thread1);  
        t1.Start();  
  
        // Thread 2  
        var t2 = new Thread(Thread2);  
        t2.Start();  
  
        t1.Join();  
        t2.Join();  
    }  
  
    public static void Thread1()  
    {  
        lock (lock1)  
        {  
            Thread.Sleep(100); // Simulate some work  
            lock (lock2)  
            {  
                Console.WriteLine("Thread 1 acquired both locks");  
            }  
        }  
    }  
  
    public static void Thread2()  
    {  
        lock (lock2)  
        {  
            Thread.Sleep(100); // Simulate some work  
            lock (lock1)  
            {  
                Console.WriteLine("Thread 2 acquired both locks");  
            }  
        }  
    }  
}

AutoResetEvent

AutoResetEvent 可用于同步线程之间的通信。

以下是它的工作原理:

  • 创建 AutoResetEvent => 已创建事件

  • 第一个线程在事件 => 上调用 WaitOne(),线程 1 等待事件发布

  • 第二个线程完成其工作并在事件 => 线程 1 上调用 set() 可以执行其工作

     static AutoResetEvent autoResetEvent = new AutoResetEvent(false);  
    
      static void Thread1()  
      {  
          autoResetEvent.WaitOne(); // Wait for the event to be signaled  
          // Perform work here  
      }  
    
      static void Thread2()  
      {  
          // Simulate some work with a sleep  
          Thread.Sleep(2000);   
          autoResetEvent.Set(); // Signal the event to release one waiting thread  
      }
    

现在,我们在线程之间进行了通信,只要对方可以执行其工作,它们就可以相互发送信号。

为了实现可靠的通信,最好使用两个 AutoResetEvent 以避免锁定。这样,两个线程都可以在需要时发出 Set 和 Wait 信号。

线程性能问题

启动新线程在性能方面成本高昂,原因如下:

  • 内存分配

创建新线程时,系统会为其堆栈和线程控制块 (TCB) 分配内存。

分配和初始化这些资源会消耗内存和时间。

  • 操作系统 (OS) 开销

操作系统必须管理每个线程的生命周期。这些操作需要 CPU 周期,并导致启动线程的开销。

  • 线程初始化

创建线程不是即时的,它需要分配资源、设置执行环境并通知调度程序,这需要时间。

解决方案是改用线程池。

鲍勃原创

IV/ 线程池

System.Threading.ThreadPool 类提供工作线程池。还可以使用线程池线程。

使用线程池而不是每次都创建新线程,通过重用现有线程来提高性能。

这是#过程:

  • 线程池接收任务

  • 线程池分配线程

  • 线程执行任务

  • 线程返回到池

线程池流

.NET Framework 提供了一个内置类,可以轻松使用线程池,而无需手动管理线程。ThreadPool

因此,我们不必像之前那样创建线程,而是将线程排队到线程池

ThreadPool.QueueUserWorkItem(Worker);  
  
void Worker()  
{  
    Console.WriteLine("Task executed.");  
}  

托管线程池中的线程是后台线程。

我们可以随时查看线程的可用性,池中的最大线程数和最小线程数,还可以设置它们!

// get available threads  
ThreadPool.GetAvailableThreads(out int workerThreads, out int completionPortThreads);  
  
// get max threads  
ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads);  
  
// get min threads  
ThreadPool.GetMinThreads(out int minWorkerThreads, out int minCompletionPortThreads);  
  
// set max threads  
ThreadPool.SetMaxThreads(8, 8);  
  
// set min threads  
ThreadPool.SetMinThreads(4, 4);

即使使用线程池,在使用共享数据时仍应同步线程。

V/ 同步机制

该类可与 .NET 的同步原语。

这些机制有助于管理对共享资源的访问,确保数据一致性并防止争用情况。

互斥

互斥(互斥的缩写)是一种同步原语,可确保一次只能有一个线程获取锁。

Mutex mutex = new Mutex();  
  
for (int i = 0; i < 5; i++)  
{  
   Thread thread = new Thread(EnterCriticalSection);  
   thread.Start(i);  
}  
  
  
void EnterCriticalSection(object threadId)  
{  
  mutex.WaitOne(); // Acquire the mutex lock  
  
  try  
  {        
      Thread.Sleep(1000); // Simulate work  
  }  
  
  finally  
  {  
      mutex.ReleaseMutex(); // Release the mutex lock  
  }  
}

信号

信号量是一种同步原语,用于限制可以同时访问资源的线程数。

它维护可用资源的计数,并在计数为零时阻止线程。

Semaphore semaphore = new Semaphore(2, 2); // Allow 2 threads at a time  
  
  
for (int i = 0; i < 5; i++)  
{  
    Thread thread = new Thread(EnterCriticalSection);  
    thread.Start(i);  
 }  
      
  
    static void EnterCriticalSection(object threadId)  
    {  
        semaphore.WaitOne(); // Acquire the semaphore  
        try  
        {  
            // Critical section: Access shared resource  
            Thread.Sleep(1000); // Simulate work  
        }  
        finally  
        {  
            semaphore.Release(); // Release the semaphore  
        }  
    }

监视器(锁定)

该类提供了一种对资源进行独占访问的机制,类似于在 C# 中使用关键字。Monitorlock

它确保一次只能有一个线程执行关键代码部分。

static object lockObject = new object();  
  
  
for (int i = 0; i < 5; i++)  
{  
     Thread thread = new Thread(EnterCriticalSection);  
     thread.Start(i);  
    }  
 }  
  
static void EnterCriticalSection(object threadId)  
{  
   lock (lockObject) // Acquire the lock  
    {  
      Thread.Sleep(1000); // Simulate work  
    }  
}

VI/ 线程管理

前景线程与后台线程

默认情况下,在 .NET 中创建的线程是前台线程,这意味着它们使应用程序保持活动状态,直到它们完成。

但是,您可以将线程显式设置为后台线程,当所有前台线程都完成执行时,该线程将自动终止。

// Explicitly sets the thread as background  
thread.IsBackground = true;

线程上下文

线程上下文包括线程无缝恢复执行所需的所有信息。这包括 CPU 寄存器、堆栈和其他相关数据。

// To find-out about the current state of a thread (running, background, stopped, aborted...)  
 var threadState = thread.ThreadState;

将数据传递到线程

Lambda 表达式通常用于初始化线程并向其传递数据。

// only one argument can be passed  
var thread = new Thread(() => Operation("Hello"));private void Operation(string name)  
 {  
     Console.WriteLine("Hello from thread" + name);  
 }

但是,在将共享变量传递给线程时要小心,以避免争用条件。最好使用常量或局部变量。

const string greeting = "Hello";  
var thread = new Thread(() => Operation(greeting));

线程优先级

线程可以具有不同的优先级,这决定了它们的执行顺序。

优先级越高的线程接收更多的 CPU 时间。默认优先级为"正常"。

thread.Priority = ThreadPriority.Highest;  
  
// Possible options are:  
Lowest  
BelowNormal  
Normal  
AboveNormal  
Highest

线程本地存储

该类支持使用该类的线程本地存储。

这允许每个线程拥有自己唯一的数据,从而确保线程安全并防止数据损坏。

ThreadLocal<int> threadLocalValue = new ThreadLocal<int>(() => 0);

调试线程

可以为线程指定一个名称,以便于调试

thread.Name = "Bob Thread";

调试方便

您可以通过以下方式获取正在使用的线程的信息:

ConsoleWriteLine("Main thread's ID: " + Thread.CurrentThread.ManagedThreadId);

或者,在调试时,您还可以查看哪个线程正在做什么

在 Visual Studio 中,调试时可以选择查看线程

这将给出以下窗口

VII/ 在 .Net 中使用线程的推荐方法

  • 不要使用 Thread.Abort

因为它会强制终止线程,类似于在该线程上引发异常。请改用取消令牌。

  • 对于需要不同资源的任务,请使用多个线程,并避免将多个线程分配给单个资源。

涉及 I/O 操作的任务受益于拥有自己的线程,以防止阻塞并提高整体吞吐量。

同样,用户输入处理等任务最好由专用线程处理。

ThreadPool.QueueUserWorkItem(PerformIOOperation);  
ThreadPool.QueueUserWorkItem(ProcessUserInput);
  • 务必处理线程中的异常。

线程中未处理的异常通常会终止进程。

ThreadPool.QueueUserWorkItem(DoWork);  
  
void DoWork(object state)  
{  
    try  
    {  
        // Perform work here  
    }  
    catch (Exception ex)  
    {  
        // Handle exception  
    }  
}
  • 使用 System.Threading.ThreadPool 初始化和管理线程

使用该类初始化和管理线程,尤其是对于短期任务和异步操作。System.Threading.ThreadPool

线程池可有效管理工作线程池,从而减少线程创建和销毁的开销。

ThreadPool.QueueUserWorkItem(DoWork);
  • 使用任务而不是线程!

我会在下一篇关于任务的博客中见到你!

何时使用线程?

线程提供了一定程度的控制和自定义,而任务等更高级别的抽象并不总是可以实现的。

线程为开发人员提供了对较低级别代码执行的直接控制。

这允许对资源、调度和同步进行精确管理,这在某些性能关键型或专用方案中至关重要。

虽然线程提供了更大的控制和灵活性,但它们也带来了额外的复杂性和潜在的陷阱,例如争用条件、死锁和同步问题。

因此,必须仔细考虑权衡,并根据应用程序的要求选择适当的并发模型

相关推荐
三天不学习3 分钟前
C# 中的记录类型简介 【代码之美系列】
后端·c#·微软技术·record·记录类型
Artistation Game26 分钟前
一、c#基础
游戏·unity·c#·游戏引擎
chen_2271 小时前
kanzi3.6.10 窗口插件-查找绑定信息
c#·kanzi
Murphy20234 小时前
.net4.0 调用API(form-data)上传文件及传参
开发语言·c#·api·httpwebrequest·form-data·uploadfile·multipart/form-
我曾经是个程序员4 小时前
C#Directory类文件夹基本操作大全
服务器·开发语言·c#
鸿喵小仙女5 小时前
C# WPF读写STM32/GD32单片机Flash数据
stm32·单片机·c#·wpf
一个不正经的林Sir5 小时前
C#WPF基础介绍/第一个WPF程序
开发语言·c#·wpf
码农君莫笑16 小时前
使用blazor开发信息管理系统的应用场景
数据库·信息可视化·c#·.net·visual studio
可喜~可乐18 小时前
C# WPF开发
microsoft·c#·wpf
666和7771 天前
C#的单元测试
开发语言·单元测试·c#