Skip to content

ФоновыеЗадания: ПолучитьТекущее и ПолучитьФоновыеЗадания не потокобезопасны, а поиск текущего задания идет за O(n) #1718

Description

@nixel2007

Суть

ФоновыеЗадания.ПолучитьТекущее() и ФоновыеЗадания.ПолучитьФоновыеЗадания() перебирают список заданий без синхронизации. Если в этот момент другой поток запускает задание (Выполнить() добавляет элемент в тот же список), перебор срывается:

Внешнее исключение (System.InvalidOperationException): Collection was modified; enumeration operation may not execute.

Дополнительно ПолучитьТекущее() ищет задание линейным перебором, хотя ищет по Task.CurrentId — то есть по ключу. Ожидается возврат за O(1).

Воспроизведение

Проверено на OneScript 2.1.0, Linux. Падает стабильно, 4 задания из 4:

Процедура Пустышка() Экспорт
КонецПроцедуры

Процедура ДергатьТекущее() Экспорт
	Для Счетчик = 1 По 20000 Цикл
		ФоновыеЗадания.ПолучитьТекущее();
	КонецЦикла;
КонецПроцедуры

Задания = Новый Массив;
Для Номер = 1 По 4 Цикл
	Задания.Добавить(ФоновыеЗадания.Выполнить(ЭтотОбъект, "ДергатьТекущее", Новый Массив, Истина));
КонецЦикла;

// одновременно наполняем список заданий из основного потока
Для Номер = 1 По 300 Цикл
	ФоновыеЗадания.Выполнить(ЭтотОбъект, "Пустышка", Новый Массив);
КонецЦикла;

Для Каждого Задание Из Задания Цикл
	Задание.ОжидатьЗавершения(60000);
	Если Задание.ИнформацияОбОшибке <> Неопределено Тогда
		Сообщить(КраткоеПредставлениеОшибки(Задание.ИнформацияОбОшибке));
	КонецЕсли;
КонецЦикла;

Вывод:

Внешнее исключение (System.InvalidOperationException): Collection was modified; enumeration operation may not execute.
Внешнее исключение (System.InvalidOperationException): Collection was modified; enumeration operation may not execute.
Внешнее исключение (System.InvalidOperationException): Collection was modified; enumeration operation may not execute.
Внешнее исключение (System.InvalidOperationException): Collection was modified; enumeration operation may not execute.

То же самое воспроизводится и на ПолучитьФоновыеЗадания().

Где это в коде

src/OneScript.StandardLibrary/Tasks/BackgroundTasksManager.cs (номера строк по e6fb8191):

  • строка 30 — хранилище: private List<BackgroundTask> _tasks = new List<BackgroundTask>();
  • строка 49 — Выполнить(): _tasks.Add(task); без блокировки
  • строка 188 — ПолучитьТекущее(): _tasks.FirstOrDefault(x => x.TaskId == (int) currentId && x.State == TaskStateEnum.Running); без блокировки и линейным поиском
  • строки 138 и 141 — ПолучитьФоновыеЗадания(): _tasks.ToArray() и foreach (var task in _tasks) без блокировки
  • строка 115 — единственное место с lock (_tasks), в ОжидатьЗавершенияЗадач(). Взаимного исключения это не дает, потому что остальные обращения блокировку не берут.

Про O(1)

ПолучитьТекущее() ищет по Task.CurrentId, то есть по целочисленному ключу. Словарь TaskId -> BackgroundTask дал бы поиск за O(1) вместо прохода по всему списку.

Сейчас цена растет линейно, причем неограниченно: завершенные задания остаются в _tasks до Очистить(). Замер, 10000 вызовов ПолучитьТекущее() изнутри задания:

Заданий в реестре Время
0 26 мс
201 32 мс
502 60 мс

Долгоживущее приложение, которое порождает задания, со временем платит за каждый вызов все больше.

Ожидаемое поведение

  1. Обращения к списку заданий потокобезопасны: одновременный запуск задания и опрос списка не приводят к исключению.
  2. ПолучитьТекущее() возвращает задание за O(1).
  3. Желательно, чтобы завершенные задания не накапливались в реестре бесконечно, либо чтобы их удержание не влияло на стоимость поиска текущего.

Одно решение закрывает все три пункта: хранить задания в ConcurrentDictionary<int, BackgroundTask> с ключом по TaskId.

Обходной путь

Со стороны прикладного кода — только повторять обращение, пока оно не пройдет:

Функция ТекущееЗадание()
	Попыток = 0;
	Пока Истина Цикл
		Попытка
			Возврат ФоновыеЗадания.ПолучитьТекущее();
		Исключение
			Попыток = Попыток + 1;
			Если Попыток >= 100 Тогда
				ВызватьИсключение;
			КонецЕсли;
		КонецПопытки;
		Приостановить(1);
	КонецЦикла;
КонецФункции

Откуда это всплыло

В библиотеке nixel2007/entity пул соединений привязывает коннектор к контексту исполнения, а контекст определяется через ФоновыеЗадания.ПолучитьТекущее(). Вызов приходится на каждый захват соединения, поэтому под нагрузкой гонка стреляла регулярно: примерно один прогон тестов из пяти падал, каждый раз в разном месте.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions