C#线程间通信-加锁

线程间通信的一种很常见的方式就是共享资源。但多线程同时访问相同资源时,会导致数据混乱,产生不可预料的结果。

经典案例-银行账户

在本例中,对余额为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));
        }
    }
}

运行程序会发现,余额是变化的,每次执行的结果还不一样。

C#线程间通信-加锁

加锁

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;
        }
        
        // ...
    }

}

再次运行程序,可以发现余额保持不变。

C#线程间通信-加锁

线程锁

线程锁一般来说,都具有 获取锁 和 释放锁 两个操作,这两个操作之间能够保证同一时间只有一个线程执行。

线程锁有不同的种类,常用的有 自旋锁,互斥锁,读写锁。

自旋锁

自旋锁是最简单的线程锁,基于原子操作实现。当一个线程获取锁对象的时候,如果锁被其他线程获取,那么这个线程会循环等待,然后不断的判断锁是否获取成功,直到获取到锁,才会退出循环。

  • • 自旋锁代码应该尽量短,否则可能会导致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)是一个特殊用途的线程锁,分为读取锁和写入锁。 读取锁可以被多个线程同时获取,写入锁不可以被多个线程获取,而且读取锁和写入锁不可以被不同的线程同时读取。

读写锁也是一个混合锁,在获取锁时通过自旋重试一定的次数再进入等待状态。