C# 多线程入门指南 – wiki基地


C# 多线程入门指南:拥抱并发,提升性能与响应性

随着现代计算机硬件的发展,特别是多核处理器的普及,单线程应用程序已经越来越难以充分利用计算资源,也无法满足用户对于流畅、无阻塞用户界面的需求。多线程(Multi-threading)技术应运而生,它允许程序同时执行多个代码路径(线程),极大地提升了应用程序的性能、响应性和吞吐量。

对于初学者而言,多线程可能是一个既令人兴奋又充满挑战的领域。理解其核心概念、掌握创建和管理线程的方法、以及解决并发带来的潜在问题(如竞态条件和死锁)是入门的关键。本文将深入浅出地带你走进 C# 多线程的世界,从基础概念讲起,逐步介绍不同的线程实现方式以及常用的同步技术。

第一章:多线程基础概念

在深入代码之前,我们需要先理解一些基本概念。

1.1 什么是进程(Process)?

进程是操作系统分配资源的基本单位。一个进程通常包含一个独立的内存空间、文件句柄、安全凭据等。当我们启动一个应用程序时,操作系统就会为其创建一个进程。例如,你打开一个 Word 文档,就是一个 Word 进程;打开一个浏览器标签页,通常是该浏览器进程下的一个线程(或多个线程,取决于浏览器实现)。进程之间是相互独立的,一个进程的崩溃通常不会影响其他进程。

1.2 什么是线程(Thread)?

线程是进程内的执行单元,是 CPU 调度的基本单位。一个进程可以包含一个或多个线程。与进程不同,同一进程内的所有线程共享该进程的资源,比如内存空间、打开的文件等。正是因为共享资源,线程之间的通信和协作更加方便,但也带来了同步的问题。

想象一下一个公司(进程):公司里有不同的部门(资源),比如销售部、研发部、财务部。每个部门都有自己的工作任务。多线程就像是公司里的多位员工(线程)在同时开展工作。他们共享公司的办公空间、网络、打印机等资源,但每个人执行不同的具体任务。

1.3 并发(Concurrency) vs. 并行(Parallelism)

  • 并发 (Concurrency): 指的是系统能够处理多个任务。在单核 CPU 上,并发是通过时间片轮转实现的,CPU 在不同线程之间快速切换,使得每个线程都能向前推进一点,宏观上看就像是同时在运行,但微观上看,在任何一个时刻,只有一个线程真正在 CPU 上执行。
  • 并行 (Parallelism): 指的是系统能够同时执行多个任务。这需要多核 CPU 的支持,不同的线程可以在不同的 CPU 核上同时执行。

因此,多线程是实现并发的一种手段,在多核环境下,多线程可以进一步实现并行,从而真正缩短总的执行时间。

1.4 为什么需要多线程?

  • 提升应用程序响应性: 将耗时操作(如文件读写、网络请求、复杂计算)放在后台线程中执行,主线程(通常是UI线程)就不会被阻塞,用户界面保持流畅可响应。
  • 提升性能和吞吐量: 充分利用多核处理器的计算能力,并行处理多个任务,加快程序整体执行速度。
  • 简化程序设计: 某些问题天然适合分解为多个独立的、可以并行或并发执行的子任务。
  • 实现后台任务: 执行一些不需要用户直接交互的监控、维护、日志记录等任务。

第二章:使用 System.Threading.Thread 类(传统方式)

System.Threading.Thread 类是 .NET Framework 和 .NET Core 中最基本的线程创建方式。虽然现代 C# 开发中更推荐使用 Task 和 async/await,但理解 Thread 类有助于理解底层机制。

2.1 创建和启动线程

使用 Thread 类创建线程,你需要为其指定一个委托,这个委托包含了线程要执行的代码。这个委托可以是 ThreadStartParameterizedThreadStart

  • ThreadStart: 用于线程执行的方法不需要任何参数。
  • ParameterizedThreadStart: 用于线程执行的方法需要一个参数(类型为 object)。

示例 2.1.1: 使用 ThreadStart

“`csharp
using System;
using System.Threading;

public class BasicThreadExample
{
public static void DoWork()
{
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 正在执行工作…”);
// 模拟耗时操作
Thread.Sleep(2000);
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 工作完成.”);
}

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

    // 1. 创建一个 Thread 对象,传入 ThreadStart 委托
    Thread workerThread = new Thread(DoWork);

    // 2. 启动线程
    workerThread.Start();

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行...");

    // 防止主线程过早结束,可以看到子线程的输出
    // 在实际应用中,你可能需要更复杂的同步或等待机制
    // Thread.Sleep(3000); // 简单等待

    // 或者使用 Join 等待线程结束 (见下一节)
    // workerThread.Join();

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

运行上述代码,你会看到主线程启动后立即输出“主线程继续执行…”,而子线程的输出会在稍后出现,证明了多线程是并发执行的。

示例 2.1.2: 使用 ParameterizedThreadStart

“`csharp
using System;
using System.Threading;

public class ParameterizedThreadExample
{
public static void DoWorkWithParameter(object data)
{
string message = data as string; // 注意参数是 object 类型,需要强制转换
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 收到参数: {message}”);
Thread.Sleep(2000);
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 工作完成.”);
}

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

    // 1. 创建 Thread 对象,传入 ParameterizedThreadStart 委托
    Thread workerThread = new Thread(DoWorkWithParameter);

    // 2. 启动线程并传入参数
    string parameter = "Hello from Main!";
    workerThread.Start(parameter); // 将参数传递给 Start 方法

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行...");

    // workerThread.Join(); // 等待子线程完成

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

使用 ParameterizedThreadStart 时,传递的参数必须是 object 类型。如果需要传递多个参数或强类型参数,通常会创建一个包含所有参数的类或结构体,然后将该对象作为参数传递。

2.2 等待线程完成:Join()

Thread.Join() 方法可以让当前线程(调用 Join 的线程,比如主线程)等待另一个线程(被调用 Join 方法的线程)执行完毕后再继续执行。

示例 2.2: 使用 Join

“`csharp
using System;
using System.Threading;

public class JoinExample
{
public static void DoWork()
{
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 开始工作…”);
Thread.Sleep(3000); // 模拟耗时操作
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 工作完成.”);
}

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

    Thread workerThread = new Thread(DoWork);
    workerThread.Start();

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 正在等待 workerThread 完成...");

    // 主线程等待 workerThread 结束
    workerThread.Join();

    Console.WriteLine($"workerThread 已完成,主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行.");
    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

运行此代码,你会看到主线程会在输出“正在等待 workerThread 完成…”后暂停,直到 workerThread 完成并输出其完成消息,主线程才会继续执行并输出最后两条消息。

2.3 线程的状态

一个线程在其生命周期中会经历多种状态,如 Unstarted(未启动)、Running(运行中)、WaitSleepJoin(等待、休眠或加入中)、Stopped(已停止)等。你可以通过 Thread.ThreadState 属性获取线程的当前状态(尽管这在实践中不常用,且不推荐基于状态进行复杂的控制)。

2.4 终止线程(⚠️ 注意)

历史上,Thread 类提供了 Thread.Abort() 方法来强制终止线程。然而,Abort 方法非常危险,它会在线程执行过程中抛出一个特殊的异常 ThreadAbortException,可能发生在任何时刻,导致线程无法正常清理资源(如释放锁、关闭文件等),从而引发不可预测的错误甚至进程崩溃。Thread.Abort() 在现代 .NET 中已被标记为过时且不应使用。

推荐的协作式终止线程的方式是使用 取消令牌 (Cancellation Token),这在 Task 为中心的异步编程中非常常见和有效,我们将在 Task 部分简要提及。对于传统的 Thread 类,一种简单的协作方式是在线程函数中定期检查一个标志位,如果标志位被设置,则线程自行退出。

示例 2.4: 协作式取消 (简单示例)

“`csharp
using System;
using System.Threading;

public class CooperativeCancellationExample
{
private static bool _cancelRequested = false;

public static void DoWork()
{
    Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 开始工作...");
    while (!_cancelRequested)
    {
        Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 正在进行中...");
        Thread.Sleep(500); // 模拟工作,并提供检查取消信号的机会
    }
    Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 收到取消信号,正在退出.");
}

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

    Thread workerThread = new Thread(DoWork);
    workerThread.Start();

    Console.WriteLine("按任意键请求取消...");
    Console.ReadKey();

    _cancelRequested = true; // 设置取消标志

    workerThread.Join(); // 等待线程退出
    Console.WriteLine($"workerThread 已退出,主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

这种方式要求工作线程中的代码主动、定期地检查取消标志,并做出响应。

总结 System.Threading.Thread:
* 是最底层的线程创建方式。
* 提供了细粒度的控制(优先级、后台/前台线程等)。
* 管理复杂(需要手动创建、启动、等待)。
* 终止线程困难且危险(应避免 Abort)。
* 不具备内置的返回值获取或异常传播机制(需要手动实现)。

由于上述缺点,特别是在处理大量短时任务或需要管理复杂依赖关系时,直接使用 Thread 类会变得非常繁琐。

第三章:使用线程池(Thread Pool)

创建和销毁线程是有开销的。如果应用程序需要频繁地创建和执行许多短小的任务,反复创建和销毁线程会消耗大量系统资源,反而降低效率。线程池应运而生,它维护一个线程的集合,这些线程可以在需要时重复使用,避免了频繁创建和销毁的开销。

.NET 提供了一个内置的线程池,可以通过 System.Threading.ThreadPool 类来访问。

3.1 ThreadPool.QueueUserWorkItem

最常用的线程池方法是 ThreadPool.QueueUserWorkItem。它将一个工作项(由一个 WaitCallback 委托表示)放入线程池队列中。当线程池中有可用线程时,就会从队列中取出工作项并执行。

WaitCallback 委托接受一个 object 参数。

示例 3.1: 使用 ThreadPool.QueueUserWorkItem

“`csharp
using System;
using System.Threading;

public class ThreadPoolExample
{
// 工作方法,接受一个 object 参数
public static void DoWork(object state)
{
int taskNumber = (int)state; // 将参数转换为实际类型
Console.WriteLine($”线程池线程 {Thread.CurrentThread.ManagedThreadId} 开始执行任务 {taskNumber}…”);
Thread.Sleep(1000); // 模拟工作
Console.WriteLine($”线程池线程 {Thread.CurrentThread.ManagedThreadId} 完成任务 {taskNumber}.”);
}

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

    // 向线程池队列添加 5 个工作项
    for (int i = 1; i <= 5; i++)
    {
        // 将 DoWork 方法和参数 i 加入线程池队列
        ThreadPool.QueueUserWorkItem(DoWork, i);
        Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId}: 已将任务 {i} 加入队列.");
    }

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行.");

    // 因为 ThreadPool 的线程是后台线程,主线程结束后它们也会结束
    // 为了看到所有输出,需要等待一下
    Thread.Sleep(6000); // 简单等待所有任务完成

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

运行此代码,你会发现任务不一定按照加入队列的顺序执行,而且多个任务可能会由同一个线程池线程执行。线程池会根据系统的负载动态调整其线程数量。

线程池的优点:
* 降低了线程创建和销毁的开销。
* 自动管理线程的生命周期和复用。
* 限制了系统中同时运行的线程数量,避免资源耗尽。

线程池的缺点:
* 对线程的控制粒度较粗(不能设置优先级、名称等)。
* 无法直接获取工作项的返回值或捕获异常(需要额外的代码处理)。
* 难以等待特定的工作项完成。

正因为这些限制,尤其是在需要更灵活的任务管理、结果返回、异常处理以及组合多个异步操作的场景下,Task 成为更优的选择。

第四章:现代异步编程:Task 和 Async/Await

从 .NET Framework 4.0 开始引入的 Task Parallel Library (TPL) 和 .NET Framework 4.5 (C# 5.0) 开始引入的 async/await 关键字,彻底改变了 C# 中的异步和并发编程方式。Task 代表了一个异步操作,它可能正在进行、已完成或已取消。async/await 模式则提供了一种更自然、更易读的方式来编写异步代码,使得异步代码看起来像同步代码一样。

4.1 Task 的基本使用

Task 类(在 System.Threading.Tasks 命名空间下)是对异步操作的一种更高级的抽象。它不仅可以用来表示在线程池线程上执行的 CPU 密集型任务,也可以表示 I/O 密集型任务(如文件读写、网络请求,这些操作在等待时不会占用 CPU 线程)。

4.1.1 Task.Run()

Task.Run() 是启动一个 CPU 密集型任务并让其在线程池上执行的最简单、推荐的方式。它返回一个 Task 对象。

示例 4.1.1: 使用 Task.Run

“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;

public class TaskRunExample
{
public static void DoWork()
{
Console.WriteLine($”Task 线程 {Thread.CurrentThread.ManagedThreadId} 开始工作…”);
Thread.Sleep(2000); // 模拟工作
Console.WriteLine($”Task 线程 {Thread.CurrentThread.ManagedThreadId} 完成工作.”);
}

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

    // 使用 Task.Run 启动一个工作
    Task task = Task.Run(() => DoWork()); // 使用 Lambda 表达式包裹方法调用

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行...");

    // 可以使用 task.Wait() 等待任务完成 (类似于 Thread.Join())
    // task.Wait(); // 阻塞主线程直到任务完成

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");

    // 注意:如果主线程没有等待,并且它结束得太快,可能看不到 Task 的全部输出
    // 在控制台应用中,通常需要等待 Task 完成
    task.Wait(); // 在示例中确保 Task 完成
}

}
“`

4.1.2 Task<TResult> 获取返回值

如果任务需要返回结果,可以使用 Task<TResult>

示例 4.1.2: Task<TResult> 获取返回值

“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;

public class TaskWithResultExample
{
public static int CalculateSum(object limit)
{
int n = (int)limit;
Console.WriteLine($”Task<{typeof(int).Name}> 线程 {Thread.CurrentThread.ManagedThreadId} 开始计算 1 到 {n} 的和…”);
long sum = 0;
for (int i = 1; i <= n; i++)
{
sum += i;
Thread.Sleep(10); // 模拟计算
}
Console.WriteLine($”Task<{typeof(int).Name}> 线程 {Thread.CurrentThread.ManagedThreadId} 完成计算.”);
return (int)sum; // 假设和不会超过 int 范围
}

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

    int calculationLimit = 100;
    // 使用 Task.Run 启动一个返回 int 的任务
    Task<int> sumTask = Task.Run(() => CalculateSum(calculationLimit));

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行...");

    // 获取任务结果。Value 属性会阻塞当前线程,直到任务完成并返回结果。
    int result = sumTask.Result; // sumTask.Result 也会捕获并重新抛出任务中的异常

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId}: 计算结果是 {result}");
    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

4.1.3 取消 Task (CancellationTokenSource)

Task 提供了标准的协作式取消机制,使用 CancellationTokenSourceCancellationToken

“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;

public class TaskCancellationExample
{
public static void DoCancellableWork(CancellationToken cancellationToken)
{
Console.WriteLine($”可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId} 开始工作…”);
try
{
for (int i = 0; i < 10; i++)
{
// 检查是否已请求取消
cancellationToken.ThrowIfCancellationRequested();

            Console.WriteLine($"可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId} 正在进行 {i}...");
            Thread.Sleep(500); // 模拟工作
        }
        Console.WriteLine($"可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId} 工作完成.");
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine($"可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId}: 收到取消请求,操作已取消.");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId}: 发生异常 - {ex.Message}");
    }
}

public static async Task Main(string[] args) // 注意 Main 方法可以使用 async Task
{
    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");

    // 创建一个 CancellationTokenSource
    using (var cts = new CancellationTokenSource())
    {
        CancellationToken token = cts.Token;

        // 使用 Task.Run 启动一个任务,并传递 CancellationToken
        Task workerTask = Task.Run(() => DoCancellableWork(token), token); // 也可以将 token 传给 Task.Run 本身

        Console.WriteLine("按任意键请求取消...");
        Console.ReadKey();

        // 请求取消
        cts.Cancel();

        try
        {
            // 使用 await 等待任务完成 (await 不会阻塞主线程)
            await workerTask; // 如果 Task 是取消状态,await 会抛出 OperationCanceledException
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId}: 任务已取消.");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId}: 任务发生异常 - {ex.Message}");
        }
    } // cts 会被释放

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

4.1.4 asyncawait (简介)

asyncawait 关键字并不是用来创建新线程的(Task.Run 或其他方法负责创建或使用线程)。它们是用来简化异步操作完成后延续代码的编写方式。

  • async 修饰符用于标记一个方法是异步方法,它内部可以使用 await 关键字。
  • await 关键字用于等待一个 Task 完成。当遇到 await 时,如果被等待的 Task 尚未完成,当前方法会立即返回(释放当前线程),而不会阻塞线程。当 Task 完成后,被 await 的方法的剩余部分(称为“延续”)会在合适的上下文中继续执行。

这对于 I/O 密集型操作尤其重要,因为等待 I/O 时线程可以被释放去处理其他任务,而不是空闲等待。对于 CPU 密集型任务,通常结合 Task.Run 使用 await,如上一个取消示例所示,await workerTask 等待 Task.Run 启动的任务完成,但 await 本身不会阻塞主线程(如果它是在支持异步上下文的UI或ASP.NET环境中)。

示例 4.1.4: 简单 async / await

“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;

public class AsyncAwaitExample
{
// 模拟一个异步操作
public static async Task SimulateAsyncTask()
{
Console.WriteLine($”SimulateAsyncTask 开始,线程 ID: {Thread.CurrentThread.ManagedThreadId}”);
await Task.Delay(2000); // await Task.Delay 不会阻塞当前线程
Console.WriteLine($”SimulateAsyncTask 结束,线程 ID: {Thread.CurrentThread.ManagedThreadId}”); // 继续可能在不同线程
}

public static async Task Main(string[] args) // Main 方法现在可以是 async Task
{
    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");

    // await 一个异步操作
    await SimulateAsyncTask(); // 主线程在此处不会阻塞,而是返回给调用者(操作系统)

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");

    // 由于 Main 方法是 async Task,它会等待内部的 Task 完成
    // 如果 Main 是 void 类型,它可能在 SimulateAsyncTask 完成前就退出了
}

}
“`

总结 Task 和 Async/Await:
* 是现代 C# 异步和并发编程的首选方式。
* 提供了更高级的抽象来表示异步操作。
* Task.Run 方便地在线程池上执行 CPU 密集型任务。
* Task<TResult> 支持获取返回值。
* 内置了标准的取消机制 (CancellationTokenSource/CancellationToken)。
* async/await 极大地简化了异步代码的编写和流程控制。
* await 关键字在等待异步操作时不会阻塞线程(对于 I/O 密集型或在支持异步上下文的UI/Web应用中)。

第五章:线程同步与共享数据问题

多线程的核心挑战之一是管理共享数据。由于多个线程可以同时访问和修改同一块内存,如果不加以控制,就可能出现竞态条件(Race Condition),导致程序行为不确定或产生错误结果。

5.1 竞态条件 (Race Condition)

竞态条件发生在多个线程访问和操作共享数据,并且最终结果取决于线程执行的相对顺序时。

示例 5.1: 竞态条件 (无同步)

“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;

public class RaceConditionExample
{
private static int _counter = 0;
private const int Iterations = 100000;

public static void IncrementCounter()
{
    for (int i = 0; i < Iterations; i++)
    {
        // 这里的 _counter++ 实际上不是一个原子操作
        // 它可能包含:
        // 1. 读取 _counter 的当前值
        // 2. 将值加 1
        // 3. 将新值写回 _counter
        _counter++;
    }
    Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 完成 IncrementCounter.");
}

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

    _counter = 0; // 重置计数器

    // 创建两个线程并发递增计数器
    Task task1 = Task.Run(() => IncrementCounter());
    Task task2 = Task.Run(() => IncrementCounter());

    // 等待两个任务都完成
    Task.WaitAll(task1, task2);

    Console.WriteLine($"所有线程完成.");
    // 期望的结果是 Iterations * 2
    Console.WriteLine($"最终计数器值: {_counter}"); // 实际值可能小于期望值

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

运行上述代码多次,你会发现最终的 _counter 值几乎总是小于 Iterations * 2 (200000)。这是因为当一个线程读取 _counter 的值后,在它将递增后的值写回之前,另一个线程可能已经读取了相同的旧值并完成了自己的递增。结果是某次递增丢失了。

5.2 同步机制:lock 关键字

为了解决竞态条件,我们需要确保在任何时刻,只有一个线程能够访问和修改共享资源。这称为创建临界区 (Critical Section)。C# 提供了多种同步机制,其中最简单、最常用的是 lock 关键字。

lock 关键字用于获取一个对象的互斥锁。在同一个对象的锁被释放之前,其他试图获取该对象锁的线程会被阻塞。

示例 5.2: 使用 lock 解决竞态条件

“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;

public class LockExample
{
private static int _counter = 0;
private const int Iterations = 100000;
private static readonly object _lockObject = new object(); // 用于 lock 的同步对象

public static void IncrementCounter()
{
    for (int i = 0; i < Iterations; i++)
    {
        // 使用 lock 确保对 _counter 的操作是原子的
        lock (_lockObject) // 获取 _lockObject 的锁
        {
            _counter++; // 临界区:只有持有锁的线程才能执行这里的代码
        } // 释放 _lockObject 的锁 (lock 块结束时自动释放)
    }
    Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 完成 IncrementCounter.");
}

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

    _counter = 0; // 重置计数器

    Task task1 = Task.Run(() => IncrementCounter());
    Task task2 = Task.Run(() => IncrementCounter());

    Task.WaitAll(task1, task2);

    Console.WriteLine($"所有线程完成.");
    // 使用 lock 后,结果应该总是 Iterations * 2
    Console.WriteLine($"最终计数器值: {_counter}"); // 实际值应该总是 200000

    Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}

}
“`

运行这段代码,你会发现最终的 _counter 值总是正确的 200000。lock 确保了 _counter++ 操作的原子性。

使用 lock 的注意事项:
* lock 后面跟着的必须是一个引用类型的对象(不能是值类型或 null)。通常创建一个 private readonly object 作为锁对象。
* 不要锁定公共可访问的对象(如 thistypeof(MyClass) 或字符串字面量),因为这可能导致外部代码意外地锁定同一个对象,引发死锁或性能问题。
* 锁的粒度很重要。锁定太大的代码块会限制并发性,锁定太小的代码块可能无法覆盖整个临界区。

5.3 其他同步原语 (Synchronization Primitives)

除了 lock,.NET 还提供了其他更高级或适用于特定场景的同步机制:

  • Monitor 类: lock 关键字实际上是 Monitor 类的语法糖。Monitor 提供了更灵活的功能,如 Enter (获取锁)、Exit (释放锁)、Wait (释放锁并等待信号)、Pulse/PulseAll (发送信号)。通常在需要等待/通知模式时使用 MonitorManualResetEvent/AutoResetEvent
  • Mutex (互斥量): 类似于 lock,但 Mutex 可以用于跨多个进程的同步。
  • Semaphore / SemaphoreSlim (信号量): 限制同时可以访问某个资源的线程数量。SemaphoreSlimSemaphore 的轻量级版本,适用于进程内的同步。
  • ReaderWriterLockSlim: 允许多个线程同时读取共享资源,但在写入时只允许一个线程访问。适用于读多写少的场景。
  • Interlocked 类: 提供对整数类型变量进行原子操作(如递增、递减、交换、比较并交换)的静态方法。比使用 lock 进行简单的原子操作效率更高。在上面的竞态条件示例中,如果只是简单递增/递减,可以使用 Interlocked.Increment(ref _counter)
  • Concurrent 集合类 (System.Collections.Concurrent): 提供了线程安全的集合(如 ConcurrentBag<T>, ConcurrentQueue<T>, ConcurrentDictionary<TKey, TValue>),在许多情况下可以直接使用它们,而无需手动加锁。

对于初学者,掌握 lock 通常就足以应对很多并发场景。随着深入学习,再逐步了解并使用其他同步原语。

5.4 死锁 (Deadlock)

死锁是多线程编程中一个常见且棘手的问题。当两个或多个线程互相持有对方所需的资源(通常是锁),并且都在等待对方释放资源时,就会发生死锁。

死锁发生的四个必要条件 (Coffman 条件):
1. 互斥 (Mutual Exclusion): 资源不能被多个线程共享,一次只能被一个线程使用。
2. 持有并等待 (Hold and Wait): 线程持有一个或多个资源,同时等待获取其他已经被别的线程持有的资源。
3. 不可剥夺 (No Preemption): 资源不能被强制从持有它的线程那里抢占,只能由持有资源的线程自愿释放。
4. 循环等待 (Circular Wait): 存在一个线程等待链,其中每个线程都在等待链中的下一个线程所持有的资源。

示例:简单的概念死锁

“`csharp
// 线程 1:
lock (lockA)
{
// 执行一些操作
lock (lockB) // 尝试获取 lockB
{
// 执行操作
}
}

// 线程 2:
lock (lockB)
{
// 执行一些操作
lock (lockA) // 尝试获取 lockA
{
// 执行操作
}
}
“`

如果线程 1 获取了 lockA,同时线程 2 获取了 lockB,然后线程 1 尝试获取 lockB(已经被线程 2 持有),线程 2 尝试获取 lockA(已经被线程 1 持有),两个线程就会互相等待,形成死锁。

避免死锁的策略:
* 避免嵌套锁: 尽量不要在一个锁内部尝试获取另一个锁。如果必须,确保所有线程以相同的顺序获取锁。
* 避免不必要的锁: 只在真正需要保护共享资源的临界区使用锁。
* 设置锁的超时时间: 使用 Monitor.TryEnterMutex.WaitOne 的带超时参数的版本,如果无法在指定时间内获取锁,则放弃或采取其他恢复措施。
* 检测死锁: 在复杂系统中,可以使用工具或技术来检测死锁的可能性。

死锁通常难以调试,因为它们依赖于线程执行的时序,可能只在特定条件下发生。

第六章:UI 线程与后台线程交互

在桌面应用程序(如 WPF 或 Windows Forms)中,用户界面元素(控件)通常只能由创建它们的线程访问和修改,这个线程就是 UI 线程。如果在后台线程中直接尝试修改 UI 控件,会导致“跨线程操作无效”的异常。

这是因为 UI 控件通常不是线程安全的,直接从多个线程访问会导致不可预测的行为。UI 框架强制要求所有对 UI 的操作都必须在 UI 线程上执行,以保证线程安全。

要从后台线程更新 UI,你需要将更新操作调度 (marshal) 回到 UI 线程。不同的 UI 框架提供不同的机制:

  • Windows Forms: 使用 Control.InvokeControl.BeginInvoke
  • WPF: 使用 Dispatcher.InvokeDispatcher.BeginInvoke
  • UWP: 使用 CoreDispatcher.InvokeAsyncDispatcherQueue.TryEnqueue

现代的 async/await 模式在 UI 应用中非常友好。当你 await 一个 Task(如 Task.Run 启动的任务或 I/O 密集型任务)完成后,紧随 await 后面的代码(延续)会尝试回到 await 发生时的上下文(通常是 UI 线程上下文),从而允许你在延续代码中安全地更新 UI,无需手动使用 Invoke/BeginInvoke

示例(概念性 – WPF):

“`csharp
// 假设这是一个 WPF 窗体类中的方法
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
// 在 UI 线程上启动任务,不会阻塞 UI
// await Task.Run(() =>
// {
// // 模拟耗时操作
// Thread.Sleep(5000);
// // !!! 错误 !!! 不能在这里直接更新 UI
// // myTextBlock.Text = “Done!”;
// }); // Task.Run 会在线程池线程上执行

// 使用 await 后的代码会自动回到 UI 线程(默认行为)
string result = await Task.Run(() =>
{
    // 模拟耗时计算并返回结果
    Thread.Sleep(5000);
    return "计算完成!";
}); // Task.Run 在线程池上

// 任务完成后,await 后的代码在 UI 线程上执行
myTextBlock.Text = result; // 安全更新 UI

}
“`
注意,实际的 UI 编程需要引用相应的 UI 框架库并设置好项目类型。此处仅为概念说明。

第七章:多线程编程的最佳实践

  • 优先使用 Task 和 Async/Await: 这是现代 C# 中处理异步和并发的首选方式,它们提供了更强大的功能、更好的可读性和更易于管理的生命周期(包括取消和错误处理)。
  • 区分 CPU 密集型和 I/O 密集型任务:
    • CPU 密集型任务:使用 Task.Run 将其放到线程池执行,避免阻塞 UI 线程或其他重要线程。
    • I/O 密集型任务:使用 .NET 提供的异步方法(如 Stream.ReadAsync, HttpClient.GetAsync 等),它们在等待 I/O 完成时不会占用线程。
  • 最小化共享可变状态: 这是避免并发问题(尤其是竞态条件)最有效的策略。尽量设计线程之间通过消息传递或不可变数据结构进行通信,而不是直接共享和修改数据。
  • 谨慎使用同步原语: 只在必要时使用 lock 或其他同步机制。过度使用锁会限制并发性,可能导致性能下降甚至死锁。
  • 使用协作式取消: 避免使用 Thread.Abort。利用 CancellationTokenSourceCancellationToken 实现线程或 Task 的友好退出。
  • 处理异常: 后台线程中未捕获的异常默认会终止进程(在 .NET Framework 中)或被忽略(在 .NET Core/5+ 中,除非特别配置)。使用 Task 时,异常会被存储在 Task 对象中,并通过 await 或访问 Result 属性时重新抛出,这使得异常处理更加方便。
  • 注意 UI 更新: 永远不要在后台线程直接更新 UI 控件。使用 UI 框架提供的机制(如 Invoke/BeginInvoke 或利用 async/await 的上下文切换)将更新操作调度回 UI 线程。
  • 小心死锁: 理解死锁的产生原因,并遵循避免死锁的策略,尤其是在使用多个锁时。

总结

多线程是现代软件开发中一项不可或缺的技术,它能够显著提升应用程序的性能和用户体验。从传统的 System.Threading.Thread 类到更高效的线程池,再到现代 C# 中强大易用的 Task 和 async/await 模式,.NET 平台提供了丰富的工具来应对不同的并发场景。

掌握多线程的关键在于理解其基本概念(进程、线程、并发、并行)、学会如何启动和管理线程/任务、以及最重要的——如何安全地处理共享数据和避免常见的陷阱(如竞态条件和死锁)。

对于初学者,建议从理解 Task 和 async/await 开始,因为它们是构建大多数现代异步应用的推荐方式。然后,深入学习同步机制,理解为什么需要它们以及如何正确使用 lock 等原语来保护共享资源。随着经验的积累,你可以逐步探索线程池的更高级用法和其他同步原语。

多线程的世界充满了挑战,但也充满了机遇。通过不断地学习和实践,你将能够编写出更高效、更稳定、响应更流畅的 C# 应用程序。祝你在多线程学习之路上取得成功!


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部