线程间通信的一种很常见的方式就是共享资源。但多线程同时访问相同资源时,会导致数据混乱,产生不可预料的结果。
经典案例-银行账户
在本例中,对余额为1000的账户分别进行存钱和取钱操作,并保证金额相同。理论上余额应该保持不变。.
/// <summary>
/// 银行账户类
/// </summary>
public class Account
{
/// <summary>
/// 账户金额
/// </summary>
private decimal balance;
/// <summary>
/// 初始化账户
/// </summary>
/// <param name="initialBalance"></param>
public Account(decimal initialBalance)
{
balance = initialBalance;
}
/// <summary>
/// 取钱
/// </summary>
/// <param name="amount"></param>
/// <returns></returns>
public decimal Debit(decimal amount)
{
if (amount < 0)
{
throw new ArgumentOutOfRangeException(nameof(amount), "The debit amount cannot be negative.");
}
decimal appliedAmount = 0;
if (balance >= amount)
{
balance -= amount;
appliedAmount = amount;
}
Console.WriteLine($"debit-amount={appliedAmount}, balance={balance}, thread={Thread.CurrentThread.ManagedThreadId}");
return appliedAmount;
}
/// <summary>
/// 存钱
/// </summary>
/// <param name="amount"></param>
public void Credit(decimal amount)
{
if (amount < 0)
{
throw new ArgumentOutOfRangeException(nameof(amount), "The credit amount cannot be negative.");
}
balance += amount;
Console.WriteLine($"credit-amount={amount}, balance={balance}, thread={Thread.CurrentThread.ManagedThreadId}");
}
/// <summary>
/// 查询余额
/// </summary>
/// <returns></returns>
public decimal GetBalance()
{
return balance;
}
}
/// <summary>
/// 测试类
/// </summary>
class Program
{
static void Main(string[] args)
{
AccountTest();
Console.ReadLine();
}
/// <summary>
/// 账户测试类
/// </summary>
static async void AccountTest()
{
var account = new Account(1000);
var tasks = new Task[10];
for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = Task.Run(() => Update(account));
}
await Task.WhenAll(tasks);
Console.WriteLine($"Account`s balance is {account.GetBalance()}");
}
static void Update(Account account)
{
// 取钱和存钱 金额是分别相等的
// 理论上最后的余额应该要保持不变
decimal[] amounts = { 1, 2, -1, -2 };
foreach (var amount in amounts)
{
if (amount >= 0)
account.Credit(amount);
else
account.Debit(Math.Abs(amount));
}
}
}
运行程序会发现,余额是变化的,每次执行的结果还不一样。
加锁
lock 可以确保单个线程具有锁对象的独占访问权。当一个线程位于代码的临界区时,另一个线程不会进入该临界区。如果其他线程尝试进入锁定的代码,则会一直等待,知道该锁对象被释放。
lock (x)
{
// code
}
对上面例子使用锁,可以确保同时调用 Debit 或 Credit 方法的两个线程无法同时更新balance字段。
/// <summary>
/// 银行账户类
/// </summary>
public class Account
{
private readonly object balanceLock = new object();
public decimal Debit(decimal amount)
{
// ...
lock(balanceLock)
{
if (balance >= amount)
{
balance -= amount;
appliedAmount = amount;
}
}
// ...
}
public void Credit(decimal amount)
{
// ...
lock (balanceLock)
{
balance += amount;
}
// ...
}
}
再次运行程序,可以发现余额保持不变。
线程锁
线程锁一般来说,都具有 获取锁 和 释放锁 两个操作,这两个操作之间能够保证同一时间只有一个线程执行。
线程锁有不同的种类,常用的有 自旋锁,互斥锁,读写锁。
自旋锁
自旋锁是最简单的线程锁,基于原子操作实现。当一个线程获取锁对象的时候,如果锁被其他线程获取,那么这个线程会循环等待,然后不断的判断锁是否获取成功,直到获取到锁,才会退出循环。
-
• 自旋锁代码应该尽量短,否则可能会导致CPU使用过高
-
• 自旋锁是不公平的,有的线程可能一直无法获取锁处于循环等待中
-
• 自旋锁可以减少线程的切换
.NET可以使用以下的类实现自旋锁:
-
• System.Threading.Thread.SpinWait
-
• System.Threading.SpinWait
-
• System.Threading.SpinLock
互斥锁
由于自旋锁不适用长时间运行,场景有限。更通用的线程锁是基于原子操作与线程调度实现的互斥锁(Mutex)。
互斥锁在获取失败时,不会反复重试,而是进入等待状态并把线程对象添加到锁关联的对象中。另一个线程释放锁时会检查队列并通知操作系统唤醒等待线程。
混合锁
.NET提供了更通用而且更高效的混合所(Monitor),任何引用类型的对象都可以作为锁对象,不需要事先创建指定类型的实例,并且涉及的非托管资源由.NET自动释放。
// lock 语句是对 Monitor 代码的简化
object __lockObj = x;
bool __lockWasTaken = false;
try
{
System.Threading.Monitor.Enter(__lockObj, ref __lockWasTaken);
// Your code...
}
finally
{
if (__lockWasTaken)
System.Threading.Monitor.Exit(__lockObj);
}
-
• 混合锁如果第一次获取失败,但其他线程马上释放了锁,当前线程在下一轮重试可以获取成功,不需要毫米级的线程调度处理。
-
• 如果其线程在短时间没有释放锁,线程会在超过重试次数后进入等待,避免CPU资源的消耗。
读写锁
读写锁(ReaderWriterLock)是一个特殊用途的线程锁,分为读取锁和写入锁。 读取锁可以被多个线程同时获取,写入锁不可以被多个线程获取,而且读取锁和写入锁不可以被不同的线程同时读取。
读写锁也是一个混合锁,在获取锁时通过自旋重试一定的次数再进入等待状态。