从小白到大神:后端开发必会之并发编程

349 阅读12分钟

并发是现代编程中的一个关键概念,指的是系统同时处理多个任务的能力。它经常与并行混淆,但两者是不同的。并发专注于构造和交错任务,使程序能够同时推进多个任务,即使任务并不是同时运行的。相反,并行涉及在不同的处理器或核心上同时执行多个任务。

在后端开发中,并发对于构建可扩展和高效的应用程序至关重要。它允许服务器同时处理多个客户端请求,从而提高响应速度和资源利用率。例如,Web 服务器可以通过在处理一个用户的数据库查询时,允许另一个用户上传文件,从而避免让用户不必要地等待。

在实现并发时,开发人员通常需要在同步和异步编程之间做出选择。同步编程一次处理一个任务,等待一个任务完成后再开始下一个任务。这种方法可能会导致延迟,尤其是在涉及 I/O 密集型任务(如读取磁盘或等待 API 响应)时。另一方面,异步编程允许任务在不阻塞程序的情况下运行。对于进行 HTTP 请求或读取文件等 I/O 密集型操作来说,异步编程非常有用,因为程序在等待任务完成的同时,可以执行其他工作。

对于 CPU 密集型任务,使用同步编程可能更简单易行。然而,在处理涉及长时间等待的任务时,异步编程对于保持响应性和可扩展性至关重要。


在 C# 中使用并发:核心概念与示例

C# 提供了强大的工具和框架来实现并发。以下是一些基础概念及其实用应用:

基于任务的异步模式 (TAP)

基于任务的异步模式是 C# 中现代异步编程的基石。TaskTask<T> 表示可以异步运行的工作单元。它们使开发人员能够以结构化的方式编写非阻塞代码。例如:

public async Task<int> FetchDataAsync()
{
    HttpClient client = new HttpClient();
    string response = await client.GetStringAsync("https://example.com");

    return response.Length;
}

在这个示例中,HttpClient.GetStringAsync 方法在获取数据时不会阻塞主线程,从而允许程序执行其他任务。

asyncawait 关键字简化了异步编程,使开发人员能够编写看起来像同步代码的异步代码。await 关键字会暂停方法的执行,直到文件读取操作完成,但不会阻塞调用线程,从而提高效率。

线程 (Threads)

线程是并发的基本构建块。它们提供了一种较低级别的机制来并发运行多个任务。然而,线程需要谨慎管理,以避免竞争条件和死锁等问题。

public void RunTaskOnThread()
{
    Thread thread = new Thread(() =>
    {
        Console.WriteLine("在单独的线程上运行");
    });
    thread.Start();
}

尽管线程功能强大,但与任务相比,它们具有更高的开销和复杂性,因此在大多数场景中更推荐使用任务。

并行编程 (Parallel Programming)

对于涉及可以分配到多个线程的重复任务的场景,Parallel.ForParallel.ForEach 提供了一种轻松实现并行的方法。

public void ProcessDataInParallel(int[] data)
{
    Parallel.For(0, data.Length, i =>
    {
        data[i] *= 2; // 模拟处理
        Console.WriteLine($"处理索引 {i} 的线程 {Thread.CurrentThread.ManagedThreadId}");
    });
}

在这个示例中,Parallel.For 循环将数据数组的处理分配到多个线程,从而在多核系统上最大化资源利用率。

这些核心概念构成了在 C# 中编写并发和可扩展应用程序的基础。通过了解何时以及如何使用 TaskThreadasync/await 和并行编程结构,开发人员可以有效地应对实际后端系统中的并发挑战。


高级并发特性

随着应用程序变得越来越复杂,利用高级并发特性对于优化性能和资源管理至关重要。在C#中,像线程池任务调度器异步流等功能提供了强大的工具,能够更高效、更灵活地处理并发操作。

线程池

线程池是.NET运行时提供的受管线程池,帮助优化线程管理。与其反复创建和销毁线程,线程池维护一组可重用的线程,从而减少线程创建带来的开销。当你需要执行短时任务时,可以使用ThreadPool.QueueUserWorkItem等方法将任务加入线程池。

例如:

public void UseThreadPool()
{
    ThreadPool.QueueUserWorkItem(_ =>
    {
        Console.WriteLine($"任务在线程 {Thread.CurrentThread.ManagedThreadId} 上执行");
    });
}

在这个例子中,任务被加入线程池,线程池中的某个线程在空闲时将执行该任务。这种方法对于I/O密集型或后台任务特别有利,因为它避免了不必要的线程创建,从而提高资源利用率。

任务调度器

C#中的TaskScheduler负责管理任务的排队和执行。默认情况下,使用TaskScheduler.Default来处理任务执行,但你也可以创建自定义任务调度器,以控制任务的优先级或线程分配。

例如,自定义任务调度器可以优先处理某些关键任务或限制并发任务的数量。以下是创建LimitedConcurrencyLevelTaskScheduler的简单示例:

public class LimitedConcurrencyLevelTaskScheduler : TaskScheduler
{
    private readonly int _maxDegreeOfParallelism;
    private readonly LinkedList<Task> _tasks = new LinkedList<Task>();
    private int _runningTasks = 0;

    public LimitedConcurrencyLevelTaskScheduler(int maxDegreeOfParallelism)
    {
        _maxDegreeOfParallelism = maxDegreeOfParallelism;
    }

    protected override IEnumerable<Task> GetScheduledTasks() => _tasks;

    protected override void QueueTask(Task task)
    {
        lock (_tasks)
        {
            _tasks.AddLast(task);
            TryExecuteNextTask();
        }
    }

    private void TryExecuteNextTask()
    {
        if (_runningTasks < _maxDegreeOfParallelism && _tasks.Count > 0)
        {
            var task = _tasks.First.Value;
            _tasks.RemoveFirst();
            _runningTasks++;
            ThreadPool.QueueUserWorkItem(_ =>
            {
                TryExecuteTask(task);
                lock (_tasks)
                {
                    _runningTasks--;
                    TryExecuteNextTask();
                }
            });
        }
    }

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false;
}

这个调度器限制并发度,确保不会有超过指定数量的任务同时运行。这种自定义调度器在速率限制或优先处理特定工作负载等场景中非常有用。

异步流

C# 8.0引入的异步流(IAsyncEnumerable<T>)使开发者能够异步处理数据流。与同步集合不同,异步流允许你逐个获取和处理元素,无需在等待数据时阻塞。

例如,使用IAsyncEnumerable<T>处理来自模拟异步数据源的数据:

public async IAsyncEnumerable<int> GenerateNumbersAsync(int count)
{
    for (int i = 0; i < count; i++)
    {
        await Task.Delay(100); // 模拟延迟
        yield return i;
    }
}

public async Task ProcessNumbersAsync()
{
    await foreach (var number in GenerateNumbersAsync(5))
    {
        Console.WriteLine($"处理的数字:{number}");
    }
}

在这个例子中,使用await foreach迭代由GenerateNumbersAsync生成的异步流。这种方法在处理分页API、流式大数据集或事件驱动数据等场景中特别有价值。

通过掌握这些高级并发特性,开发者可以对线程、任务管理和异步数据处理进行精细控制。这些工具不仅能够提升应用程序性能,还能为处理复杂的并发工作负载提供更加稳健和可扩展的解决方案。


线程安全与同步

确保线程安全是并发编程中最关键的方面之一。如果处理不当,多个线程访问共享资源可能导致不可预测的行为、数据损坏,甚至应用程序崩溃。本节探讨线程安全、常见问题以及 C# 中可用的并发访问管理工具。

理解线程安全

线程安全意味着确保当多个线程同时访问共享资源时,程序仍能正确运行。线程安全的操作保证,无论线程如何调度或交错执行,程序都会产生一致且正确的结果。这在后端应用中尤为重要,因为多个用户或进程可能会同时与相同的资源交互。

常见问题

在并发编程中,有几个问题可能会危及线程安全:

竞态条件:当程序的结果取决于无法控制的事件(如两个线程同时修改同一变量)执行顺序或时机时,就会发生竞态条件。

int counter = 0;
void IncrementCounter()
{
    for (int i = 0; i < 1000; i++)
    {
        counter++;
    }
}

如果多个线程并发调用 IncrementCounter(),由于同时访问,counter 的最终值可能会不正确。

死锁:当两个或多个线程彼此等待释放资源时,会发生死锁,导致无限循环的等待。

lock (obj1)
{
    lock (obj2)
    {
        // 这里的代码
    }
}

如果另一个线程先锁定 obj2 然后尝试锁定 obj1,就会发生死锁。

线程饥饿:当低优先级线程因高优先级线程持续占用资源而无法访问资源时,就会发生线程饥饿。

同步技术

C# 提供了多种同步原语,用于安全地管理对共享资源的并发访问:

lock 语句:简化了获取和释放 Monitor 的过程,确保一次只有一个线程可以访问关键部分。

private static readonly object _lockObj = new object();

void SafeIncrement()
{
    lock (_lockObj)
    {
        counter++;
    }
}

Monitor 类:提供比 lock 更精细的控制,允许显式等待或信号其他线程。

Monitor.Enter(_lockObj);

try
{
    // 关键区域
}
finally
{
    Monitor.Exit(_lockObj);
}

SemaphoreSlim:限制可以同时访问资源的线程数。适用于限制资源访问的情况。

SemaphoreSlim semaphore = new SemaphoreSlim(2); // 限制为 2 个线程

async Task AccessResourceAsync()
{
    await semaphore.WaitAsync();

    try
    {
        // 访问共享资源
    }
    finally
    {
        semaphore.Release();
    }
}

ReaderWriterLockSlim:通过允许多个读者或一个写者来优化读写访问。

ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();

void ReadData()
{
    rwLock.EnterReadLock();

    try
    {
        // 读取数据
    }
    finally
    {
        rwLock.ExitReadLock();
    }
}

void WriteData()
{
    rwLock.EnterWriteLock();
    try
    {
        // 写入数据
    }
    finally
    {
        rwLock.ExitWriteLock();
    }
}

不可变数据

不可变性是一种强大的策略,可以完全避免线程安全问题。不可变对象在创建后无法修改,因此是固有的线程安全的,因为没有线程可以改变其状态。

例如,使用 readonly 字段和属性,或使用 System.Collections.Immutable 等不可变数据结构,可以确保并发场景中的数据完整性:

public class ImmutableCounter
{
    public int Value { get; }

    public ImmutableCounter(int value)
    {
        Value = value;
    }

    public ImmutableCounter Increment() => new ImmutableCounter(Value + 1);
}

由于 ImmutableCounter 在每次值改变时都会创建一个新实例,多个线程可以安全地操作它,而无需担心共享状态的修改。

掌握线程安全和同步技术对于编写健壮、可靠和高性能的并发应用至关重要。通过理解竞态条件和死锁等常见陷阱,并利用同步原语或不可变性,开发人员可以构建能够优雅处理并发工作负载的可扩展系统。


现实案例:处理高并发

在后端开发中,处理高并发是一个常见的挑战,尤其是在设计需要每秒处理数千个请求的 API 时。本节将探讨构建可扩展、高效系统的技术和最佳实践,以管理高负载情况。

问题场景

假设你正在为一个电商平台开发 RESTful API,该平台在闪购期间会遇到流量激增。产品列表和订单处理的 API 端点需要在性能不降低或系统不中断的情况下处理数千个并发请求。

扩展技术

  1. 使用 Task.Runasync/await 进行异步请求处理

异步编程允许服务器处理多个 I/O 密集型操作而不会阻塞线程。这对于涉及数据库查询或第三方服务交互的 API 调用至关重要。

public async Task<IActionResult> GetProductsAsync()
{
    var products = await _productService.FetchProductsAsync();
    return Ok(products);
}
  1. 使用 SemaphoreSlim 进行限流

为了防止系统过载,可以使用 SemaphoreSlim 限制并发请求数。这在访问有限资源(如数据库)时特别有用。

private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(100); // 限制 100 个并发请求

public async Task<IActionResult> ProcessOrderAsync(Order order)
{
    await _semaphore.WaitAsync();
    try
    {
        await _orderService.ProcessOrderAsync(order);
        return Ok();
    }
    finally
    {
        _semaphore.Release();
    }
}
  1. 缓存频繁请求的数据

实现缓存可以通过将频繁请求的数据存储在内存中或分布式缓存(如 Redis)中来减轻数据库负担。这提高了响应速度并增强了系统的可扩展性。

public async Task<Product> GetProductDetailsAsync(int productId)
{
    if (_cache.TryGetValue(productId, out Product product))
    {
        return product;
    }

    product = await _productService.FetchProductFromDbAsync(productId);
    _cache.Set(productId, product, TimeSpan.FromMinutes(10));

    return product;
}

性能考虑

线程池管理:.NET 线程池会自动管理线程分配,但通过监控和调整线程池设置可以提升性能。避免手动创建过多线程,因为这会导致上下文切换开销。

垃圾回收优化:高并发应用会生成大量短生命周期对象。优化垃圾回收设置或使用对象池可以提升性能。根据服务器工作负载调整垃圾回收配置。

监控与分析:使用 Application Insights 或 PerfView 等工具监控性能指标,如请求延迟、CPU 使用率和线程池耗尽。及早识别瓶颈有助于保持应用健康。

设计可扩展系统

负载均衡:使用负载均衡器将请求分发到多个服务器。这降低了单个服务器过载的风险,并确保高可用性。

分布式系统:将应用拆分为微服务,每个微服务处理特定功能。这种模块化方法允许根据负载独立扩展每个服务。

云原生工具:使用 Kubernetes 等云服务部署应用。Kubernetes 提供自动扩展、负载均衡和容错功能,非常适合管理高并发工作负载。

示例架构:

• 使用 Kubernetes 的水平 Pod 自动扩展器(HPA)根据 CPU 或内存使用情况自动调整运行的 Pod 数量。

• 使用 Azure Redis Cache 或 AWS ElastiCache 等云缓存解决方案进行分布式缓存。

处理高并发场景需要结合高效的编码实践、仔细的资源管理和稳健的架构设计。通过利用异步编程、实施限流机制和设计可扩展系统,你可以构建在高负载下保持性能的后端服务。持续监控和优化将确保系统在需求增长时依然具有弹性和响应能力。


结论

理解并发编程对于构建高效、可扩展的后端系统至关重要。它使开发人员能够同时处理多个任务,从而提高应用的响应能力和资源利用率。掌握并发概念能够设计更好地管理高负载并提供无缝用户体验的解决方案。

选择合适的工具和技术至关重要,因为每个项目都有独特的需求。虽然任务和线程等基本概念是基础,但线程池、任务调度器和同步机制等高级功能在优化性能和确保线程安全方面发挥关键作用。评估何时使用异步编程或并行处理可以显著提高系统效率。

并发编程是一个广阔的领域,永远都有更多内容可以探索。反应式编程和 System.Threading.Channels 等高级库为处理复杂场景提供了强大的工具。持续学习和实践将帮助你保持领先,从而设计出强健、高性能的后端系统,满足现代应用的需求。