Суть
ФоновыеЗадания.ПолучитьТекущее() и ФоновыеЗадания.ПолучитьФоновыеЗадания() перебирают список заданий без синхронизации. Если в этот момент другой поток запускает задание (Выполнить() добавляет элемент в тот же список), перебор срывается:
Внешнее исключение (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 мс |
Долгоживущее приложение, которое порождает задания, со временем платит за каждый вызов все больше.
Ожидаемое поведение
- Обращения к списку заданий потокобезопасны: одновременный запуск задания и опрос списка не приводят к исключению.
ПолучитьТекущее() возвращает задание за O(1).
- Желательно, чтобы завершенные задания не накапливались в реестре бесконечно, либо чтобы их удержание не влияло на стоимость поиска текущего.
Одно решение закрывает все три пункта: хранить задания в ConcurrentDictionary<int, BackgroundTask> с ключом по TaskId.
Обходной путь
Со стороны прикладного кода — только повторять обращение, пока оно не пройдет:
Функция ТекущееЗадание()
Попыток = 0;
Пока Истина Цикл
Попытка
Возврат ФоновыеЗадания.ПолучитьТекущее();
Исключение
Попыток = Попыток + 1;
Если Попыток >= 100 Тогда
ВызватьИсключение;
КонецЕсли;
КонецПопытки;
Приостановить(1);
КонецЦикла;
КонецФункции
Откуда это всплыло
В библиотеке nixel2007/entity пул соединений привязывает коннектор к контексту исполнения, а контекст определяется через ФоновыеЗадания.ПолучитьТекущее(). Вызов приходится на каждый захват соединения, поэтому под нагрузкой гонка стреляла регулярно: примерно один прогон тестов из пяти падал, каждый раз в разном месте.
Суть
ФоновыеЗадания.ПолучитьТекущее()иФоновыеЗадания.ПолучитьФоновыеЗадания()перебирают список заданий без синхронизации. Если в этот момент другой поток запускает задание (Выполнить()добавляет элемент в тот же список), перебор срывается:Дополнительно
ПолучитьТекущее()ищет задание линейным перебором, хотя ищет поTask.CurrentId— то есть по ключу. Ожидается возврат за O(1).Воспроизведение
Проверено на OneScript 2.1.0, Linux. Падает стабильно, 4 задания из 4:
Вывод:
То же самое воспроизводится и на
ПолучитьФоновыеЗадания().Где это в коде
src/OneScript.StandardLibrary/Tasks/BackgroundTasksManager.cs(номера строк поe6fb8191):private List<BackgroundTask> _tasks = new List<BackgroundTask>();Выполнить():_tasks.Add(task);без блокировкиПолучитьТекущее():_tasks.FirstOrDefault(x => x.TaskId == (int) currentId && x.State == TaskStateEnum.Running);без блокировки и линейным поискомПолучитьФоновыеЗадания():_tasks.ToArray()иforeach (var task in _tasks)без блокировкиlock (_tasks), вОжидатьЗавершенияЗадач(). Взаимного исключения это не дает, потому что остальные обращения блокировку не берут.Про O(1)
ПолучитьТекущее()ищет поTask.CurrentId, то есть по целочисленному ключу. СловарьTaskId -> BackgroundTaskдал бы поиск за O(1) вместо прохода по всему списку.Сейчас цена растет линейно, причем неограниченно: завершенные задания остаются в
_tasksдоОчистить(). Замер, 10000 вызововПолучитьТекущее()изнутри задания:Долгоживущее приложение, которое порождает задания, со временем платит за каждый вызов все больше.
Ожидаемое поведение
ПолучитьТекущее()возвращает задание за O(1).Одно решение закрывает все три пункта: хранить задания в
ConcurrentDictionary<int, BackgroundTask>с ключом поTaskId.Обходной путь
Со стороны прикладного кода — только повторять обращение, пока оно не пройдет:
Откуда это всплыло
В библиотеке nixel2007/entity пул соединений привязывает коннектор к контексту исполнения, а контекст определяется через
ФоновыеЗадания.ПолучитьТекущее(). Вызов приходится на каждый захват соединения, поэтому под нагрузкой гонка стреляла регулярно: примерно один прогон тестов из пяти падал, каждый раз в разном месте.