如何使用单元测试测试线程安全
单元测试线程安全
一、线程安全图解
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 来说,伪代码逻辑示意如下:
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 来说具有以下特性:
- 没有对共享资源的访问,没有线程安全问题;
- 具有可测试性,通过使用常规测试手段模式控制 TrySellOneTicket 的行为即可;
由于 SellOut 方法行为已确定,这里不再讨论。
对于 TrySellOneTicket 来说,此时方法的执行已剔除了不稳定的中间状态,已具有可测试性。
3.3 测试 TrySellOneTicket 方法
通过代码分析,TrySellOneTicket 可能存在的线程安全问题为:多个线程在查询当前存在余票时,同时执行了订票行为,从而当余票不足时,出现了余票为负的情况。
根据第一节所述线程安全出现的条件,可按以下逻辑进行测试:
- 设定余票只有1张;
- 两个线程同时进行 SellOneTicket 操作;
- 线程1 查询到尚有余票,进入代码块,在执行实际的“出票”代码前等待;
- 线程2 查询到尚有余票,进入代码块,并成功出票;
- 线程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 方法,可发现该方法实际完成了三个功能:
- if 条件流程控制;
- 查询是否有余票;
- 尝试出票;
为了方便进行单元测试,根据以上功能点,可以再次对 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
该方法中只进行了流程控制,具有以下特点:
- 没有共享对象的访问,线程安全;
- 可通过常规测试手段,对 HasAnyTicket 方法进行仿真控制,达到单元测试目的;
因此,该方法行为固定,具有可测试性,后面不再讨论。
HasAnyTicket - 该方法中只进行了逻辑判断,具有可测试性;
- 该方法虽然只进行了大小比较,由于线程缓存,存在很小几率的线程同步问题,可使用 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 测试模板》 一书