如何使用单元测试测试线程安全

单元测试线程安全

一、线程安全图解

1.1 什么情况下会出现线程安全问题

当多个线程对相同共享资源进行操作时,线程1对资源的操作状态会被其他线程修改,导致资源无法返回预期结果。
于是,对于该资源对象来说,存在线程安全问题。
时序图如下:

1.2 如何保证资源线程安全

使共享资源从被操作到状态可被有效访问这一期间,其他线程无法对该资源进行操作。即对资源进行上锁,使得资源被锁闭期间的所有操作具有原子性。
于是,对于该资源的当前操作,就可以称为线程安全。
时序图如下:

1.3 线程锁

在实际编码中,可以通过添加自旋锁或内核锁,使对资源的操作具有原子性,即保证在上锁期间对资源的操作是线程安全的。
以 lock 关键字为例,可以用如下代码保证线程安全性:

void DoSomething()
{
    lock(_lockObj)
    {
        // do something
    }
}

二、一个存在并发性问题示例

internal class TicketBookingSystem_Origin
{
    int _ticketNums = 10;
    public void SellOut()
    {
        while (true)
        {
            if (_ticketNums > 0)
            {
                Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 当前还有余票 {_ticketNums} 张");
                _ticketNums--;
                Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 售出一张票, 当前还有余票 {_ticketNums} 张");
            }
            else
            {
                Console.WriteLine("卖完了.....");
                break;
            }
        }
    }
}

尝试创建多个线程进行运行测试:

static void Main(string[] args)
{
    TicketBookingSystem_Origin ticketBookingSystem = new TicketBookingSystem_Origin();
    // 存在线程问题, 且无法测试
    ThreadPool.QueueUserWorkItem(state => ticketBookingSystem.SellOut());
    ThreadPool.QueueUserWorkItem(state => ticketBookingSystem.SellOut());
    ThreadPool.QueueUserWorkItem(state => ticketBookingSystem.SellOut());
    Console.ReadLine();
}

运行时,可能的打印信息如下:

[4][21:22:18 539] 当前还有余票 10 张
[5][21:22:18 539] 当前还有余票 10 张
[5][21:22:18 549] 售出一张票, 当前还有余票 8 张
[5][21:22:18 549] 当前还有余票 8 张
[5][21:22:18 550] 售出一张票, 当前还有余票 7 张
[5][21:22:18 550] 当前还有余票 7 张
[5][21:22:18 550] 售出一张票, 当前还有余票 6 张
[5][21:22:18 550] 当前还有余票 6 张
[4][21:22:18 549] 售出一张票, 当前还有余票 9 张
[4][21:22:18 550] 当前还有余票 5 张
[4][21:22:18 550] 售出一张票, 当前还有余票 4 张
[4][21:22:18 550] 当前还有余票 4 张
[4][21:22:18 550] 售出一张票, 当前还有余票 3 张
[4][21:22:18 550] 当前还有余票 3 张
[4][21:22:18 550] 售出一张票, 当前还有余票 2 张
[4][21:22:18 550] 当前还有余票 2 张
[3][21:22:18 549] 当前还有余票 10 张
[3][21:22:18 550] 售出一张票, 当前还有余票 0 张
卖完了.....
[5][21:22:18 550] 售出一张票, 当前还有余票 5 张
卖完了.....
[4][21:22:18 550] 售出一张票, 当前还有余票 1 张
卖完了.....

由打印信息可知,上述代码存在明显的线程安全问题。

三、使用单元测试检测线程问题

3.1 可测试性分析

分析 Sellout 方法,方法体如下所示:

public void SellOut()
{
    while (true)
    {
        if (_ticketNums > 0)
        {
            Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 当前还有余票 {_ticketNums} 张");
            _ticketNums--;
            Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 售出一张票, 当前还有余票 {_ticketNums} 张");
        }
        else
        {
            Console.WriteLine("卖完了.....");
            break;
        }
    }
}

该方法作用是将当前所有余票全部卖光。由于方法在运行时,无法对方法运行过程中的状态实时进行诊断。因此若对该方法进行单元测试,只能当方法执行结束后,对余票进行断言:

// arrange
CountdownEvent cde = new CountdownEvent(3);
TicketBookingSystem_Origin ticketBookingSystem = new TicketBookingSystem_Origin();
// action
ThreadPool.QueueUserWorkItem(state => { ticketBookingSystem.SellOut(); cde.Signal(); });
ThreadPool.QueueUserWorkItem(state => { ticketBookingSystem.SellOut(); cde.Signal(); });
ThreadPool.QueueUserWorkItem(state => { ticketBookingSystem.SellOut(); cde.Signal(); });
cde.Wait();
// Assert
Assert.AreEqual(ticketBookingSystem._ticketNums, 0);

实际运行中大概率测试通过,小几率测试不通过,_ticketNums 为负值。
由于测试结果的不确定性,因此该测试用例无效,该方法不可被单元测试。

3.2 重构版本1

分析 SellOut 方法后可发现,该方法实际上主要完成了以下两个功能:

  1. 当某个条件满足后跳出循环;
  2. 卖出一余票张票;
    对于功能 1 来说,伪代码逻辑示意如下:
public void SellOut()
{
    while(true)
    {
        if(卖出余票失败)
        {
            break;
        }
    }
}

于是,将 SellOut 方法重构如下:

public void SellOut()
{
    while(true)
    {
        if(TrySellOneTicket() == false)
        {
            break
        }
    }
}
public bool TrySellOneTicket()
{
    if(_ticketNums > 0)
    {
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 当前还有余票 {_ticketNums} 张");
        _ticketNums--;
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 售出一张票, 当前还有余票 {_ticketNums} 张");
    }
    else
    {
        Console.WriteLine("票卖完了。");
        return false;
    }
}

此时对于 SellOut 来说具有以下特性:

  1. 没有对共享资源的访问,没有线程安全问题;
  2. 具有可测试性,通过使用常规测试手段模式控制 TrySellOneTicket 的行为即可;
    由于 SellOut 方法行为已确定,这里不再讨论。
    对于 TrySellOneTicket 来说,此时方法的执行已剔除了不稳定的中间状态,已具有可测试性。

3.3 测试 TrySellOneTicket 方法

通过代码分析,TrySellOneTicket 可能存在的线程安全问题为:多个线程在查询当前存在余票时,同时执行了订票行为,从而当余票不足时,出现了余票为负的情况。
根据第一节所述线程安全出现的条件,可按以下逻辑进行测试:

  1. 设定余票只有1张;
  2. 两个线程同时进行 SellOneTicket 操作;
  3. 线程1 查询到尚有余票,进入代码块,在执行实际的“出票”代码前等待;
  4. 线程2 查询到尚有余票,进入代码块,并成功出票;
  5. 线程1 等待结束,进行出票;
    若希望使用以上测试用例进行测试,则需要对 TrySellOneTicket 方法添加“出票前等待功能”:
// 供外部进行出票前等待控制
public bool WaitingBeforeDrawTicket {get;set;} = false;
public bool TrySellOneTicket()
{
    if(_ticketNums > 0)
    {
        // 若需要进行出票前等待,则等待3秒钟
        if(WaitingBeforeDrawTicket) Thread.Sleep(3000);
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 当前还有余票 {_ticketNums} 张");
        _ticketNums--;
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 售出一张票, 当前还有余票 {_ticketNums} 张");
    }
    else
    {
        Console.WriteLine("票卖完了。");
        return false;
    }
}

测试用例如下:

// arrange
CountdownEvent cde = new CountdownEvent(2);
TicketBookingSystem_Ver1 ticketBookingSystem = new TicketBookingSystem_Ver1();
ticketBookingSystem._ticketNums = 1;
int thread1RestTicket = 0;
// action
ThreadPool.QueueUserWorkItem(state =>
{
    ticketBookingSystem.WaitingBeforeDrawTicket = true;
    ticketBookingSystem.SellOneTicket();
    thread1RestTicket = ticketBookingSystem._ticketNums;
    cde.Signal();
});
ThreadPool.QueueUserWorkItem(state =>
{
    ticketBookingSystem.WaitingBeforeDrawTicket = false;
    ticketBookingSystem.SellOneTicket();
    cde.Signal();
});
cde.Wait()
// assert
Assert.IsEqual(thread1RestTicket, 0);

上述测试用例运行失败,期望 thread1RestTicket 结果为 0, 实际为 -1。
至此,线程安全问题已通过单元测试成功测出。

3.4 重构版本2

分析重构的 TrySellOneTicket 方法,可发现该方法实际完成了三个功能:

  1. if 条件流程控制;
  2. 查询是否有余票;
  3. 尝试出票;
    为了方便进行单元测试,根据以上功能点,可以再次对 TrySellOneTicket 进行重构:
// 供外部进行出票前等待控制
public bool WaitingBeforeDrawTicket {get;set;} = false;
public bool TrySellOneTicket()
{
    if(HasAnyTicket())
    {
        DrawOneTicket();
    }
    else
    {
        Console.WriteLine("票卖完了。");
        return false;
    }
}
public bool HasAnyTicket()
{
    return _ticketNums > 0;
}
public void DrawOneTicket()
{
    // 若需要进行出票前等待,则等待3秒钟
    if(WaitingBeforeDrawTicket) Thread.Sleep(3000);
    Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 当前还有余票 {_ticketNums} 张");
    _ticketNums--;
    Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 售出一张票, 当前还有余票 {_ticketNums} 张");
}

对重构代码的可测试性进行分析:
TrySellOneTicket
该方法中只进行了流程控制,具有以下特点:

  1. 没有共享对象的访问,线程安全;
  2. 可通过常规测试手段,对 HasAnyTicket 方法进行仿真控制,达到单元测试目的;
    因此,该方法行为固定,具有可测试性,后面不再讨论。
    HasAnyTicket
  3. 该方法中只进行了逻辑判断,具有可测试性;
  4. 该方法虽然只进行了大小比较,由于线程缓存,存在很小几率的线程同步问题,可使用 volatile 关键字修复;
    该方法行为简单,后续不再讨论;
    DrawOneTicket
    该方法功能相较于重构前的 TrySellOneTicket 方法,职责更清晰,可测试性更强。
    将 3.3 节测试用例稍作修改:
// arrange
CountdownEvent cde = new CountdownEvent(2);
TicketBookingSystem_Ver2 ticketBookingSystem = new TicketBookingSystem_Ver2();
ticketBookingSystem._ticketNums = 1;
int thread1RestTickets = 0;
// act
ThreadPool.QueueUserWorkItem(state =>
{
    ticketBookingSystem.WaitingBeforeDrawTicket = true;
    ticketBookingSystem.DrawTicketFromSystem();
    thread1RestTickets = ticketBookingSystem._ticketNums;
    cde.Signal();
});
ThreadPool.QueueUserWorkItem(state =>
{
    ticketBookingSystem.WaitingBeforeDrawTicket = false;
    ticketBookingSystem.DrawTicketFromSystem();
    cde.Signal();
});
cde.Wait();
// assert
Assert.IsEqual(thread1RestTicket, 0);

上述测试用例运行失败,期望 thread1RestTicket 结果为 0, 实际为 -1。

3.5 移除测试代码对生产代码的侵入

由于被测试代码已经抽取成最小逻辑单元,封装成独立方法。
因此可以使用一些常规测试手段来移除 TicketBookingSystem_Origin 类中侵入的测试代码。
如,在测试类中添加如下内部类型:

internal class TicketBookingSystem_Origin_Sub : TicketBookingSystem_Origin
{
    internal void DrawTicketFromSystem(int DelayTime)
    {
        Thread.Sleep(DelayTime);
        base.DrawTicketFromSystem();
    }
}

重构后的完整 TicketBookingSystem_Origin 代码如下:

internal class TicketBookingSystem_Ver3
{
    public int _ticketNums = 10;
    public void SellOut()
    {
        while (true)
        {
            if (TrySellOneTicket() == false)
            {
                break;
            }
        }
    }
    public bool TrySellOneTicket()
    {
        if(HasAnyTicket())
        {
            DrawOneTicket();
        }
        else
        {
            Console.WriteLine("票卖完了。");
            return false;
        }
    }
    public bool HasAnyTicket()
    {
        return _ticketNums > 0;
    }
    public void DrawOneTicket()
    {
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 当前还有余票 {_ticketNums} 张");
        _ticketNums--;
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 售出一张票, 当前还有余票 {_ticketNums} 张");
    }
}

更新后的,可测试上述模块线程安全性的测试用例如下:

 // arrange
CountdownEvent cde = new CountdownEvent(2);
TicketBookingSystem_Origin_Sub ticketBookingSystem = new TicketBookingSystem_Origin_Sub();
ticketBookingSystem._ticketNums = 1;
int thread1RestTickets = 0;
// act
ThreadPool.QueueUserWorkItem(state =>
{
    ticketBookingSystem.DrawTicketFromSystem(1000);
    thread1RestTickets = ticketBookingSystem._ticketNums;
    cde.Signal();
});
ThreadPool.QueueUserWorkItem(state =>
{
    ticketBookingSystem.DrawTicketFromSystem(0);
    cde.Signal();
});
cde.Wait();
// assert
Assert.IsEqual(thread1RestTicket, 0);

测试结果依然失败,期望值为0,实际为 -1;

四、修改后的可测试通过代码

internal class TicketBookingSystem_Ver3
{
    private object _lockObj = new object();
    public volatile int _ticketNums = 10;
    public void SellOut()
    {
        while (true)
        {
            if (TrySellOneTicket() == false)
            {
                break;
            }
        }
    }
    public bool TrySellOneTicket()
    {
        if(HasAnyTicket())
        {
            if(TryDrawOneTicket() == false)
            {
                return false;
            }
        }
        else
        {
            Console.WriteLine("票卖完了。");
            return false;
        }
    }
    public bool HasAnyTicket()
    {
        return _ticketNums > 0;
    }
    public bool TryDrawOneTicket()
    {
        lock(_lockObj)
        {
            if(HasAnyTicket() == false) return false;
            Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 当前还有余票 {_ticketNums} 张");
            _ticketNums--;
            Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}][{DateTime.Now.ToString("HH:mm:ss fff")}] 售出一张票, 当前还有余票 {_ticketNums} 张");
        }
    }
}

此时,测试用例通过。
至此,一个不可测试的、存在线程安全的问题方法,向高可测试性、且能够进行线程测试的方法改造已完成。

五、总结

5.1 使用单元测试测试线程问题的前提

需要进行准确的代码分析。需要能够分析出哪些操作可能存在线程问题。
即需要明确知道哪里需要添加线程锁。
线程互斥问题无法使用调试工具进行辅助检测,测试中的代码覆盖率对线程问题同样没有任何帮助。

5.2 Hamble Object Pattern

即谦卑对象模式,该模式指导将不可测试的代码分割成很小的、不可再分割的逻辑单元,从而剥离出可测试的部分。
分割之后的不可测试的最小对象则称为谦卑对象。
第三节中的重构逻辑正是应用了该设计模式;

出自 《xUnit 测试模板》 一书

posted @ 2022-04-13 20:47  Hello——寻梦者!  阅读(762)  评论(0编辑  收藏  举报