Контекст синхронизации при работе с Task и Thread

Posted by PDSW on Monday, March 12, 2018
Last Modified on Thursday, September 20, 2018

При работе в проектах связанными с ui-control или asp.net часто приходится работать с ресурсами доступными в локальном хранилище потока (thread-local storage TLS). К слову: такое поведение можно задать при помощи аттрибута [ThreadStatic] или класса ThreadLocal<T>. При обращении к ним из другого потока платформа dotnet выбросит исключение System.InvalidOperationException с текстом ‘The calling thread cannot access this object because a different thread owns it.'.

Представим себе ситуацию когда в каком-то win-form приложении нам по нажатию кнопки нужно вызвать долгоиграющую задачу. Очевидно, что нужно было-бы создать поток и в этом потоке выполнить эту задачу. Проблематично будет отобразить результат выполнения из созданного потока на UI - ведь ресурсы UI принадлежат одному потоку, а обращаемся мы к нему в другом. Это и есть та вышеописанная ситуация, когда мы столкнёмся с System.InvalidOperationException. Для решения этой проблемы нам придётся подружиться с контекстом синхронизации. multithreading

Контекст синхронизации

Контекст синхронизации SynchronizationContext это абстракция позволяющая задать где, в каком потоке, должен выполнится код. В каждом потоке есть своя сущьность SynchronizationContext ассоциированная с этим потоком. Она находится в поле SynchronizationContext.Current, но это не обязательно для всех потоков.

Делегаты переданные в методы контекста синхронизации send и post будут вызваны в этом потоке. post - это асинхронная неблокирующая версия send метода.

Повторив теорию мы готовы на практике реализовать решение задачи. Посмотрите на следующий код:

private void ButtonBase_OnClick_Async(object sender, RoutedEventArgs e)
{
	// смотрим текущий идентификатор потока
	int id = Thread.CurrentThread.ManagedThreadId;
	Trace.WriteLine("ButtonBase_OnClick_Async thread: " + id);

	SynchronizationContext uiContext = SynchronizationContext.Current;

	// Создаём поток и ассоциируем его с методом
	Thread thread = new Thread(Run);
	thread.Start(uiContext);
}

private void Run(object state)
{
	// Смотрим ещё раз поток
	int id = Thread.CurrentThread.ManagedThreadId;
	Trace.WriteLine("Run thread: " + id);

	// востанавливаем контекст синхронизации из стейта потока
	SynchronizationContext uiContext = state as SynchronizationContext;

	// эмулируем долгое выполнение задачи
	Thread.Sleep(1000);
	
	// вызываем продолжение в UI потоке 
	uiContext.Post(UpdateUI, "finished");
	
}

// Этот метод будет вызван в UI потоке
private void UpdateUI(object state)
{
	int id = Thread.CurrentThread.ManagedThreadId;
	Trace.WriteLine("UpdateUI thread:" + id);
	Label.Content = state;
}

А давайте это перепишем на TPL?!

В вашем проекте скорее всего уже есть TPL, а значит глупо всё оставлять в том виде как это было ранее. После перевода потоков в таски наш код станет немного проще:

private void ButtonBase_OnClick_Async(object sender, RoutedEventArgs e)
{
	// смотрим текущий идентификатор потока
	int id = Thread.CurrentThread.ManagedThreadId;
	Trace.WriteLine("ButtonBase_OnClick_Async thread: " + id);

	Task task = Run();
}

private Task Run()
{
	return Task.Run(() =>
	{
		// смотрим текущий идентификатор потока
		int id = Thread.CurrentThread.ManagedThreadId;
		Trace.WriteLine("Run thread: " + id);
		
		// эмулируем долгое выполнение задачи
		Task.Delay(1000);
	}).ContinueWith(_ =>
	{
		UpdateUI("finished");
	}, TaskScheduler.FromCurrentSynchronizationContext()); // вызываем продолжение в UI потоке
}

private void UpdateUI(object state)
{
	int id = Thread.CurrentThread.ManagedThreadId;
	Trace.WriteLine("UpdateUI thread:" + id);
	Label.Content = state;
}  

Обратите внимание на следующий код:

.ContinueWith(_ =>
{
	UpdateUI("finished");    
}, TaskScheduler.FromCurrentSynchronizationContext())

Метод ContinueWith содержит ряд полезных перегрузок и наверное заслуживает большего внимания, но сейчас при помощи него мы указываем в каком потоке нужно выполнить продолжение - вторым параметром мы указали использовать в продолжении поток из текущего контекста синхронизации TaskScheduler.FromCurrentSynchronizationContext()

Как видим наш код стал заметно проще и лаконичнее, а самое главное позволяет нам при написании нового кода думать о значительно меньшем количестве деталей. Но несмотря на это нужно помнить, что используя такие удобные абстракции как Task, мы на самом деле используем куда более глубокие и зачастую более сложные концепции на самом деле. Так что возможное количество мест где можно самым неявным образом отстрелить себе ногу только возрасло.

Ccылка пока одна: Parallel Computing - It’s All About the SynchronizationContext


comments powered by Disqus