在C#开发中,当程序需要同时执行多个任务提升执行效率时,就会用到并发编程和多线程技术。但多个线程同时操作共享资源时,很容易出现数据不一致的问题,需要通过合理的同步机制来保障线程安全。

常见的多线程同步问题
数据竞争问题
当多个线程同时读写同一个共享变量,且没有同步控制时,就会产生数据竞争。比如两个线程同时对一个计数器做加1操作,最终的结果可能比预期值小。
以下是一个存在数据竞争问题的示例代码:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Main()
{
// 创建两个线程同时执行加1操作
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"最终计数器值: {counter}");
}
static void IncrementCounter()
{
for (int i = 0; i < 10000; i++)
{
counter++; // 多个线程同时操作共享变量,存在数据竞争
}
}
}
上面的代码中,两个线程各执行10000次加1操作,理论上最终counter的值应该是20000,但实际运行结果往往小于这个值,这就是数据竞争导致的问题。
死锁问题
死锁是指两个或多个线程互相持有对方需要的资源,并且都在等待对方释放资源,导致所有线程都无法继续执行的情况。比如线程A持有资源1等待资源2,线程B持有资源2等待资源1,就会产生死锁。
C#中处理多线程同步的常用方法
使用lock语句
lock语句是C#中最常用的同步方式,它可以保证同一时间只有一个线程能进入被锁定的代码块,从而避免数据竞争。lock语句需要一个引用类型的对象作为锁对象,通常建议使用私有的只读对象作为锁,避免使用公共对象或者this作为锁,防止外部代码意外修改锁状态。
修改后的计数器代码使用lock语句解决数据竞争问题:
using System;
using System.Threading;
class Program
{
static int counter = 0;
// 定义私有的只读锁对象
static readonly object lockObj = new object();
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"最终计数器值: {counter}");
}
static void IncrementCounter()
{
for (int i = 0; i < 10000; i++)
{
// 使用lock锁定代码块,同一时间只有一个线程能执行下面的逻辑
lock (lockObj)
{
counter++;
}
}
}
}
使用Monitor类
lock语句本质上是Monitor类的语法糖,Monitor类提供了更灵活的同步控制,比如可以尝试获取锁,设置超时时间,避免无限等待导致的死锁。Monitor类的常用方法有Enter、TryEnter、Exit等。
使用Monitor类实现带超时的锁获取示例:
using System;
using System.Threading;
class Program
{
static readonly object lockObj = new object();
static void Main()
{
// 尝试在500毫秒内获取锁
bool lockTaken = false;
try
{
Monitor.TryEnter(lockObj, 500, ref lockTaken);
if (lockTaken)
{
Console.WriteLine("成功获取锁,执行操作");
Thread.Sleep(1000);
}
else
{
Console.WriteLine("获取锁超时,放弃操作");
}
}
finally
{
if (lockTaken)
{
Monitor.Exit(lockObj);
}
}
}
}
使用Interlocked类处理简单原子操作
对于简单的数值增减、赋值等原子操作,使用Interlocked类比lock语句效率更高,因为它是由底层硬件直接支持的原子操作,不需要额外的锁开销。Interlocked类提供了Increment、Decrement、Exchange、CompareExchange等方法。
使用Interlocked类解决计数器问题的示例:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"最终计数器值: {counter}");
}
static void IncrementCounter()
{
for (int i = 0; i < 10000; i++)
{
// 使用Interlocked的原子加1操作,无需加锁
Interlocked.Increment(ref counter);
}
}
}
使用信号量控制并发数量
如果需要限制同时访问某个资源的线程数量,可以使用Semaphore或者SemaphoreSlim类。比如数据库连接池需要限制同时使用的连接数量,就可以用信号量来实现。
使用SemaphoreSlim限制同时只有3个线程执行某个操作的示例:
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static SemaphoreSlim semaphore = new SemaphoreSlim(3); // 初始允许3个线程同时进入
static async Task Main()
{
// 创建10个任务同时执行
Task[] tasks = new Task[10];
for (int i = 0; i < 10; i++)
{
int taskId = i;
tasks[i] = Task.Run(() => ExecuteTask(taskId));
}
await Task.WhenAll(tasks);
}
static async Task ExecuteTask(int taskId)
{
Console.WriteLine($"任务{taskId}等待进入");
await semaphore.WaitAsync(); // 等待获取信号量
try
{
Console.WriteLine($"任务{taskId}开始执行");
await Task.Delay(1000); // 模拟执行操作
Console.WriteLine($"任务{taskId}执行完成");
}
finally
{
semaphore.Release(); // 释放信号量
}
}
}
避免死锁的实践建议
- 尽量按照固定的顺序获取锁,所有线程都先获取资源A再获取资源B,就能避免循环等待导致的死锁。
- 使用带超时的锁获取方法,比如Monitor.TryEnter,避免线程无限等待锁。
- 减少锁的持有时间,尽量只把需要同步的代码放在锁内部,不要在锁内部执行耗时的操作比如IO操作、网络请求等。
- 避免在锁内部调用外部不可靠的代码,防止外部代码长时间阻塞导致锁无法释放。
不同同步方案的选型建议
不同的同步场景适合不同的方案,以下是简单的选型参考:
| 场景 | 推荐方案 |
|---|---|
| 简单的共享变量原子操作,比如计数器增减 | Interlocked类 |
| 普通的代码块同步,需要保证同一时间只有一个线程执行 | lock语句 |
| 需要尝试获取锁,或者设置获取锁的超时时间 | Monitor类 |
| 需要限制同时访问资源的线程数量 | SemaphoreSlim类 |
| 需要线程之间发送信号,比如一个线程等待另一个线程完成某个操作 | ManualResetEventSlim、AutoResetEvent等同步事件类 |
在实际开发中,需要根据具体的业务场景选择合适的同步方案,同时做好测试,确保多线程代码在各种情况下都能稳定运行,避免出现线程安全问题。