一、当异步遇上共享资源:致命邂逅

去年我们团队开发过一个实时数据采集系统,在某个深夜突然出现数据丢失的诡异现象。经过排查发现,当两个异步任务同时写入同一个配置文件时,后写入的数据会把前一条覆盖掉。这种看似"随机出现"的问题,根源正是异步操作中的资源竞争。

资源竞争就像超市收银台的争夺战。想象多个顾客(线程)同时冲向唯一开放的收银台(共享资源),没有排队机制时就会发生推挤碰撞。在代码世界中,这种碰撞表现为数据覆盖、状态不一致甚至程序崩溃。

二、典型战场:异步写日志引发的血案

让我们用C# 8.0和.NET Core 3.1构建一个真实案例。下面这段看似正常的代码,实则暗藏杀机:

// 危险的日志记录类
public class UnsafeLogger
{
    private StreamWriter _writer = new StreamWriter("app.log");
    
    public async Task WriteLogAsync(string message)
    {
        // 异步写入操作
        await _writer.WriteLineAsync($"{DateTime.Now}: {message}");
        await _writer.FlushAsync();
    }
}

// 使用者代码
var logger = new UnsafeLogger();
var tasks = new List<Task>();

for (int i = 0; i < 100; i++)
{
    tasks.Add(logger.WriteLogAsync($"Operation {i}"));
}

await Task.WhenAll(tasks);

运行这段代码时,你可能会发现:

  1. 日志行数少于100条
  2. 某些日志的时间戳顺序错乱
  3. 偶尔抛出"Stream was disposed"异常

这些症状的根源在于多个异步任务同时访问未受保护的StreamWriter。当Task A正在执行FlushAsync时,Task B可能已经修改了流的写入位置,导致数据混乱。

三、战术手册:三大防御策略

3.1 盾牌防御:lock关键字

public class LockLogger
{
    private readonly object _lockObj = new object();
    private StreamWriter _writer = new StreamWriter("app.log");
    
    public async Task WriteLogAsync(string message)
    {
        lock(_lockObj)
        {
            // 同步块内部不能使用await!
            _writer.WriteLine($"{DateTime.Now}: {message}");
            _writer.Flush();
        }
    }
}

这种方法将异步方法强行变为同步,虽然解决了竞争问题,但完全丧失了异步的优势。就像用铁链锁住所有收银台,虽然不会发生争夺,但结账效率直线下降。

3.2 智能调度:SemaphoreSlim

public class SemaphoreLogger
{
    private SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
    private StreamWriter _writer = new StreamWriter("app.log");
    
    public async Task WriteLogAsync(string message)
    {
        await _semaphore.WaitAsync();
        try
        {
            await _writer.WriteLineAsync($"{DateTime.Now}: {message}");
            await _writer.FlushAsync();
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

信号量就像电子排队系统,允许指定数量的并发访问(本例为1)。这里使用异步版本的WaitAsync,既保证线程安全,又保持异步特性。注意必须用try-finally确保信号量释放。

3.3 专用武器:并发集合

public class ConcurrentLogger
{
    private BlockingCollection<string> _queue = new BlockingCollection<string>();
    private readonly CancellationTokenSource _cts = new CancellationTokenSource();
    
    public ConcurrentLogger()
    {
        // 启动独立消费者线程
        Task.Run(() => ProcessQueue());
    }
    
    public void WriteLog(string message)
    {
        _queue.Add($"{DateTime.Now}: {message}");
    }
    
    private async Task ProcessQueue()
    {
        using var writer = new StreamWriter("app.log");
        foreach (var message in _queue.GetConsumingEnumerable(_cts.Token))
        {
            await writer.WriteLineAsync(message);
            await writer.FlushAsync();
        }
    }
}

这种模式将写入操作与队列处理解耦,生产者只需要往队列添加消息,由单一消费者处理实际IO操作。BlockingCollection天生线程安全,完美解决并发访问问题,特别适合高吞吐量场景。

四、战术分析:不同武器的适用战场

方案 适用场景 吞吐量 延迟 资源消耗
lock 低频简单操作
SemaphoreSlim 需要异步等待的独占访问
并发集合 高频生产-消费场景

特别注意:

  1. 避免在lock块内调用外部代码,可能引发死锁
  2. SemaphoreSlim的初始计数不要大于1,除非允许有限并发
  3. 使用并发集合时要妥善处理生产者-消费者的生命周期

五、生存法则:异步资源管理的七个禁忌

  1. 不要跨多个await共享状态:在两次await之间,线程可能已经切换
  2. 警惕闭包陷阱:异步lambda可能意外延长对象生命周期
  3. 谨慎使用静态成员:静态字段天然就是共享资源
  4. 避免async void:异常无法捕获,可能造成资源泄漏
  5. 及时释放信号量:finally块是最后的防线
  6. 监控队列积压:无界队列可能导致内存暴涨
  7. 测试要模拟真实并发:Task.WhenAll比顺序await更能暴露问题

六、实战经验:调试并发BUG的三种武器

  1. 并发可视化工具:VS的并行堆栈窗口能显示所有活动线程
  2. Thread.Sleep随机插入:人为制造竞争条件更容易复现问题
  3. Interlocked计数器:统计实际并发数是否超出预期

七、总结:构建坚不可摧的异步防线

在异步编程的世界里,资源竞争就像潜伏的幽灵。通过本文的三种解决方案和实战建议,我们可以根据具体场景选择合适的防御策略。记住:没有银弹,只有对业务场景的深刻理解加上恰当的技术选型,才能打造真正可靠的异步系统。