TDD
10 January 2012

Тестирование параллельных потоков

В дебагере можно без проблем поймать поток исполнения в правильной точке, а затем, после проведения анализа, перезапустить его. В автоматических тестах эти операции выглядят безумно сложными.

А зачем вообще это нужно?

Построение параллельных систем — дело не самое простое. Необходимо соблюдать баланс между параллельностью и синхронностью. Недосинхронизируешь — потеряешь в стабильности. Пересинхронизируешь — получишь последовательную систему.

Рефакторинг — это вообще прогулка по минному полю.

Если бы не сложность реализации, автоматическое тестирование могло бы здорово помочь. На мой взгляд, в параллельной системе неплохо было бы автоматически проверять одновременную работу отдельных конфликтных участков кода — факт и порядок выполнения.

Вот я наконец и добрался до приостановления и возобновления работы потоков. Как уже говорилось, такой не автоматический механизм существует. Это брейкпоинты. Не будем изобретать новую терминологию — брейкпоинт, значит брейкпоинт.

public class Breakpoint
{
	[Conditional("DEBUG")]
	public static void Define(string name){…}
}

public class BreakCtrl : IDisposable
{
	public string Name { get; private set; }
	public BreakCtrl(string name) {…}
	public BreakCtrl From(params Thread[] threads) {…}
	public void Dispose(){…}
	public void Run(Thread thread){…}
	public void Wait(Thread thread){…}
	public bool IsCapture(Thread thread){…}
	public Thread AnyCapture(){…}
}


Свойства автоматических брейкпоинтов:
  1. Работают только в режиме отладки (при определенном макросе DEBUG). Мы не должны задумываться, что дополнительный код повлияет на работу системы у конечного пользователя.
  2. Брейкпоинт срабатывает только если его контролер определен. Ненужные в конкретном тесте брейкпоинты не должны наводить систему (и усложнять тесты).
  3. Контролер знает, в каком состоянии находится брейкпоинт — удерживает ли он поток.
  4. Контролер способен заставить брейкпоинт отпустить поток.
  5. И необязательная привязка к конкретному потоку. Хотим управляем конкретным потоком, хотим всеми сразу.


[TestMethod]
public void StopStartThreadsTest_exemple1()
{
	var log = new List<string>();

	ThreadStart act1 = () =>
		{
			Breakpoint.Define("empty");

			Breakpoint.Define("start1");
			log.Add("after start1");

			Breakpoint.Define("step act1");
			log.Add("after step act1");

			Breakpoint.Define("finish1");
		};

	ThreadStart act2 = () =>
		{
			Breakpoint.Define("start2");
			log.Add("after start2");

			Breakpoint.Define("step act2");
			log.Add("after step act2");

			Breakpoint.Define("finish2");
		};

	using (var start1 = new BreakCtrl("start1"))
	using (var step_act1 = new BreakCtrl("step act1"))
	using (var finish1 = new BreakCtrl("finish1"))

	using (var start2 = new BreakCtrl("start2"))
	using (var step_act2 = new BreakCtrl("step act2"))
	using (var finish2 = new BreakCtrl("finish2"))
	{
		var thr1 = new Thread(act1);
		thr1.Start();
		var thr2 = new Thread(act2);
		thr2.Start();

		start1.Wait(thr1);
		start2.Wait(thr2);

		start1.Run(thr1);
		step_act1.Wait(thr1);
		step_act1.Run(thr1);
		finish1.Wait(thr1);

		start2.Run(thr2);
		step_act2.Wait(thr2);
		step_act2.Run(thr2);
		finish2.Wait(thr2);

		finish1.Run(thr1);
		finish2.Run(thr2);

		thr1.Join();
		thr2.Join();
	}

	Assert.AreEqual(4, log.Count);
	Assert.AreEqual("after start1", log[0]);
	Assert.AreEqual("after step act1", log[1]);
	Assert.AreEqual("after start2", log[2]);
	Assert.AreEqual("after step act2", log[3]);
}


Правда неудобно? Но надо смириться — ведь без тестирования не возможен рефакторинг. Всегда сначала приходится перебарывать себя… бла-бла-бла. Даже я вскоре понял, что пользоваться этим невозможно. Понял в районе второго десятка написанных тестов. Тесты получаются ненаглядные и сложные. Но…

Сложно — это хорошо. Ведь ничего кроме решения сложности я делать не умею. Немного усилий и получилось такое решение:

public class ThreadTestManager
{
	public ThreadTestManager(TimeSpan timeout, params Action[] threads){…}
	public void Run(params BreakMark[] breaks){…}
}

public class BreakMark
{
	public string Name { get; private set; }
	public Action ThreadActor { get; private set; }
	public bool Timeout { get; set; }

	public BreakMark(string breakName){…}
	public BreakMark(Action threadActor, string breakName){…}
	public static implicit operator BreakMark(string breakName){…}
}


При его использовании предыдущий тест выглядеть так:

[TestMethod]
public void StopStartThreadsTest_exemple2()
{
	var log = new List<string>();

	Action act1 = () =>
	{
		Breakpoint.Define("before start1");
		Breakpoint.Define("start1");
		log.Add("after start1");

		Breakpoint.Define("step act1");
		log.Add("after step act1");

		Breakpoint.Define("finish1");
	};

	Action act2 = () =>
	{
		Breakpoint.Define("before start2");
		Breakpoint.Define("start2");
		log.Add("after start2");

		Breakpoint.Define("step act2");
		log.Add("after step act2");

		Breakpoint.Define("finish2");
	};

	new ThreadTestManager(TimeSpan.FromSeconds(1), act1, act2).Run(
		"before start1", "before start2",
		"start1", "step act1", "finish1",
		"start2", "step act2", "finish2");
	
	Assert.AreEqual(4, log.Count);
	Assert.AreEqual("after start1", log[0]);
	Assert.AreEqual("after step act1", log[1]);
	Assert.AreEqual("after start2", log[2]);
	Assert.AreEqual("after step act2", log[3]);
}


Свойства диспетчера:
  1. Все делегаты запускаются при старте в своем потоке.
  2. Маркеры брейкпоинтов определяют порядок возобновления работы. Не входа, а выхода из брейкпоинтов. Возможно это просто издержки реализации абстракции «брейкпоинт». Но свойство есть и о нем приходится иногда вспомнить.
  3. Все контролеры для соответствующих маркеров брейкпоинтов определены на всем протяжении работы диспетчера.
  4. С маркером брейкпоинта можно указать потоком (делегат), с которым он будет работать. По умолчанию работает со всеми.

    [TestMethod]
    public void ThreadMarkBreakpointTest_exemple3()
    {
    	var log = new List<string>();
    
    	Action<string> act = name =>
    	{
    		Breakpoint.Define("start");
    		log.Add(name);
    
    		Breakpoint.Define("finish");
    	};
    
    	Action act0 = () => act("act0");
    	Action act1 = () => act("act1");
    
    	new ThreadTestManager(TimeSpan.FromSeconds(1), act0, act1).Run(
    		new BreakMark(act0, "finish"),
    		new BreakMark(act1, "start"),
    		new BreakMark(act1, "finish"));
    
    	Assert.AreEqual(2, log.Count);
    	Assert.AreEqual("act0", log[0]);
    	Assert.AreEqual("act1", log[1]);
    }
    
  5. Определено время, в течении которого должны выполнится все операции — timeout. При превышении — все потоки останавливаются грубо и беспощадно (abort).
  6. К маркеру брейкпоинта, можно добавить признак недосягаемости, не добравшись сюда система планово выйдет по timeout-у. Срабатывание брейкпоинта приведет к провалу теста. Этот механизм используется для проверки факта блокировки.

    [TestMethod]
    public void Timeout_exemple4()
    {
    	var log = new List<string>();
    
    	Action act = () =>
    	{
    		try
    		{
    			while (true) ;
    		}
    		catch (ThreadAbortException)
    		{
    			log.Add("timeout");
    		}
    
    		Breakpoint.Define("don't work");
    	};
    
    	new ThreadTestManager(TimeSpan.FromSeconds(1), act).Run(
    		new BreakMark("don't work") { Timeout = true });
    
    	Assert.AreEqual("timeout", log.Single());
    }
    
  7. Если хочется остановить выполнение потока и не продолжать его, надо указать соответствующий маркер брейкпоинта после маркера с timeout-ом.

    [TestMethod]
    public void CatchThread_exemple5()
    {
    	var log = new List<string>();
    
    	Action act0 = () =>
    	{
    		bool a = true;
    		while (a) ;
    		Breakpoint.Define("act0");
    	};
    
    	Action act1 = () =>
    	{
    		Breakpoint.Define("act1");
    		log.Add("after act1");
    	};
    
    	new ThreadTestManager(TimeSpan.FromSeconds(1), act0, act1).Run(
    		new BreakMark("act0") { Timeout = true },
    		"act1");
    
    	Assert.IsFalse(log.Any());
    }
    


PS: Solution

+20
6.7k 54
Comments 56
Top of the day