Многопоточность в БСП реализуется функциями общего модуля ДлительныеОперации: ВыполнитьФункциюВНесколькоПотоков и ВыполнитьПроцедуруВНесколькоПотоков. Вы передаёте имя экспортного серверного метода, параметры выполнения и соответствие «ключ порции → массив параметров метода». БСП запускает фоновые задания по порциям, следит за ними и после завершения кладёт сводный результат во временное хранилище.

Функцию выбирают, когда результат порций нужно собрать и обработать дальше; процедуру — когда достаточно выполнить работу и узнать, что всё прошло без ошибок. Ниже — схема, рабочий код для запуска с клиента и из регламентного задания, и ошибки, которые встречаются чаще всего. Пример ориентирован на БСП 3.1; в более ранних версиях сигнатуры отличаются, поэтому перед использованием открывайте комментарии функций в своей конфигурации.

Параметры функций многопоточности

ПараметрЧто передаём
ИмяФункции / ИмяПроцедурыСтрока "ОбщийМодуль.Метод", метод экспортный, серверный
ПараметрыВыполненияСтруктура из ДлительныеОперации.ПараметрыВыполненияФункции(ИдентификаторФормы) или ПараметрыВыполненияПроцедуры()
НаборПараметровСоответствие: ключ — идентификатор порции (номер, ссылка), значение — Массив параметров метода

Количество параметров метода в массиве ограничено (по описанию функции — не больше семи). На практике хватает одного: порции данных.

Обе функции возвращают структуру с описанием фонового задания — ту же, что ДлительныеОперации.ВыполнитьФункцию: статус, идентификатор задания, адрес результата. После завершения по адресу результата лежит соответствие с теми же ключами, что в наборе параметров, а в значениях — описание результата каждой порции, включая её статус и адрес её собственного результата.

Как спроектировать многопоточную обработку

Перед кодом решите три вопроса:

  1. Нужен ли результат порций? Да — функция; нет — процедура.
  2. Как резать данные на порции? Порции должны быть независимыми: один документ — в одном потоке, одна запись регистра не должна писаться из двух потоков.
  3. Сколько этапов? Если сначала идёт расчёт, а потом запись, разделите их. Расчёт параллелится свободно, запись — по документам.

Минимальный набор методов:

  • серверный метод, который готовит порции и запускает потоки;
  • серверный экспортный метод обработки одной порции;
  • при запуске с клиента — клиентская процедура с ожиданием и обработчик завершения;
  • при необходимости — метод постобработки собранных результатов.
Многопоточность БСП: исходные данные делятся на независимые порции
Многопоточность БСП: исходные данные делятся на независимые порции

Деление данных на порции

Вспомогательная функция, которая раскладывает массив по порциям «по кругу» и сразу формирует набор параметров:

1С
// Делит массив данных на порции и возвращает набор параметров для многопоточного выполнения.
// Ключ - номер порции, значение - массив с одним параметром (массивом элементов порции).
Функция НаборПараметровПоПорциям(Данные, ЧислоПотоков) Экспорт

	ЧислоПорций = Макс(1, Мин(ЧислоПотоков, Данные.Количество()));

	Порции = Новый Массив;
	Для Номер = 1 По ЧислоПорций Цикл
		Порции.Добавить(Новый Массив);
	КонецЦикла;

	Для Индекс = 0 По Данные.ВГраница() Цикл
		Порции[Индекс % ЧислоПорций].Добавить(Данные[Индекс]);
	КонецЦикла;

	НаборПараметров = Новый Соответствие;
	Для Номер = 1 По ЧислоПорций Цикл
		ПараметрыМетода = Новый Массив;
		ПараметрыМетода.Добавить(Порции[Номер - 1]);
		НаборПараметров.Вставить(Номер, ПараметрыМетода);
	КонецЦикла;

	Возврат НаборПараметров;

КонецФункции

Если данные — это документы с ключами, разбивайте по документам: передавайте в порцию массив ссылок, а не строки табличных частей вперемешку.

Запуск с клиента: расчёт и запись в два этапа

Этап 1. Серверный запуск расчёта

Общий модуль РасчетДокументовСервер (сервер, вызов сервера):

1С
// Запускает многопоточный расчет. Возвращает описание длительной операции.
Функция ЗапуститьРасчет(ПериодРасчета, ИдентификаторФормы, ЧислоПотоков = 8) Экспорт

	ДанныеДляРасчета = ПодготовитьДанныеДляРасчета(ПериодРасчета);
	Если ДанныеДляРасчета.Количество() = 0 Тогда
		Возврат Неопределено;
	КонецЕсли;

	НаборПараметров = НаборПараметровПоПорциям(ДанныеДляРасчета, ЧислоПотоков);

	ПараметрыВыполнения = ДлительныеОперации.ПараметрыВыполненияФункции(ИдентификаторФормы);
	ПараметрыВыполнения.НаименованиеФоновогоЗадания = НСтр("ru = 'Расчет документов'");
	ПараметрыВыполнения.ОжидатьЗавершение = 0;

	Возврат ДлительныеОперации.ВыполнитьФункциюВНесколькоПотоков(
		"РасчетДокументовСервер.РассчитатьПорцию", ПараметрыВыполнения, НаборПараметров);

КонецФункции

// Обрабатывает одну порцию. Выполняется в фоновом задании.
Функция РассчитатьПорцию(ПорцияДанных) Экспорт

	Результат = Новый Массив;
	Для Каждого Элемент Из ПорцияДанных Цикл
		// Расчет, не записывающий данные в базу
		Результат.Добавить(РассчитатьЭлемент(Элемент));
	КонецЦикла;

	Возврат Результат;

КонецФункции

ОжидатьЗавершение = 0 — вернуть управление сразу, ожидание будет на клиенте.

Клиент: ожидание и обработчик

1С
&НаКлиенте
Процедура РассчитатьДокументы(Команда)

	Задание = ЗапуститьРасчетНаСервере(Период, УникальныйИдентификатор);
	Если Задание = Неопределено Тогда
		Возврат;
	КонецЕсли;

	НастройкиОжидания = ДлительныеОперацииКлиент.ПараметрыОжидания(ЭтотОбъект);
	НастройкиОжидания.ТекстСообщения = НСтр("ru = 'Идет расчет документов...'");
	НастройкиОжидания.ВыводитьОкноОжидания = Истина;

	Обработчик = Новый ОписаниеОповещения("РассчитатьДокументыЗавершение", ЭтотОбъект);
	ДлительныеОперацииКлиент.ОжидатьЗавершение(Задание, Обработчик, НастройкиОжидания);

КонецПроцедуры

&НаСервереБезКонтекста
Функция ЗапуститьРасчетНаСервере(ПериодРасчета, ИдентификаторФормы)
	Возврат РасчетДокументовСервер.ЗапуститьРасчет(ПериодРасчета, ИдентификаторФормы);
КонецФункции

&НаКлиенте
Процедура РассчитатьДокументыЗавершение(Результат, ДополнительныеПараметры) Экспорт

	Если Результат = Неопределено Тогда
		Возврат; // Пользователь отменил операцию
	КонецЕсли;

	Если Результат.Статус <> "Выполнено" Тогда
		ОбщегоНазначенияКлиент.СообщитьПользователю(Результат.ПодробноеПредставлениеОшибки);
		Возврат;
	КонецЕсли;

	ЗапуститьЗапись(Результат.АдресРезультата);

КонецПроцедуры

Этап 2. Запись документов по порциям

Результаты порций собираем на сервере, группируем по документам и запускаем вторую волну потоков уже процедурой:

1С
// Собирает результаты потоков расчета и запускает многопоточную запись.
Функция ЗапуститьЗапись(АдресРезультатаРасчета, ИдентификаторФормы, ЧислоПотоков = 8) Экспорт

	РезультатыПорций = ПолучитьИзВременногоХранилища(АдресРезультатаРасчета);

	ДанныеПоДокументам = Новый Соответствие;
	Для Каждого КлючИЗначение Из РезультатыПорций Цикл
		ОписаниеПорции = КлючИЗначение.Значение;
		Если ОписаниеПорции.Статус <> "Выполнено" Тогда
			ВызватьИсключение СтрШаблон(НСтр("ru = 'Порция %1 не рассчитана: %2'"),
				КлючИЗначение.Ключ, ОписаниеПорции.КраткоеПредставлениеОшибки);
		КонецЕсли;
		ДобавитьРезультатыПоДокументам(ДанныеПоДокументам,
			ПолучитьИзВременногоХранилища(ОписаниеПорции.АдресРезультата));
	КонецЦикла;

	// Один документ - в одной порции, чтобы потоки не конкурировали за объект
	Документы = Новый Массив;
	Для Каждого КлючИЗначение Из ДанныеПоДокументам Цикл
		Документы.Добавить(Новый Структура("Документ, Данные", КлючИЗначение.Ключ, КлючИЗначение.Значение));
	КонецЦикла;
	НаборПараметров = НаборПараметровПоПорциям(Документы, ЧислоПотоков);

	ПараметрыВыполнения = ДлительныеОперации.ПараметрыВыполненияПроцедуры();
	ПараметрыВыполнения.НаименованиеФоновогоЗадания = НСтр("ru = 'Запись документов'");
	ПараметрыВыполнения.ОжидатьЗавершение = 0;

	Возврат ДлительныеОперации.ВыполнитьПроцедуруВНесколькоПотоков(
		"РасчетДокументовСервер.ЗаписатьПорциюДокументов", ПараметрыВыполнения, НаборПараметров);

КонецФункции

Состав описания порции (поля статуса, ошибки, адреса) сверяйте с комментарием ВыполнитьФункциюВНесколькоПотоков в своей версии БСП: от релиза к релизу он уточнялся.

Многопоточность БСП: расчет и запись выполняются двумя отдельными волнами потоков
Многопоточность БСП: расчет и запись выполняются двумя отдельными волнами потоков

Почему два этапа, а не один? Если в одном потоке и считать, и писать, а порции резать по строкам данных, два потока могут попасть в один документ: получите либо неверное заполнение, либо ожидание на блокировке. Расчёт без записи не блокирует ничего, а запись по документам не пересекается.

Запуск из регламентного задания

На сервере ждать завершения можно прямо в вызове: значение Неопределено в ОжидатьЗавершение означает «ждать, пока задание не закончится».

1С
// Обработчик регламентного задания.
Процедура РасчетДокументовРегламент() Экспорт

	ОбщегоНазначения.ПриНачалеВыполненияРегламентногоЗадания(
		Метаданные.РегламентныеЗадания.РасчетДокументов);

	Период = Новый СтандартныйПериод(НачалоМесяца(ТекущаяДатаСеанса()), КонецМесяца(ТекущаяДатаСеанса()));
	НаборПараметров = НаборПараметровПоПорциям(ПодготовитьДанныеДляРасчета(Период), 8);

	ПараметрыВыполнения = ДлительныеОперации.ПараметрыВыполненияФункции(Новый УникальныйИдентификатор);
	ПараметрыВыполнения.НаименованиеФоновогоЗадания = НСтр("ru = 'Расчет документов (регламент)'");
	ПараметрыВыполнения.ОжидатьЗавершение = Неопределено;

	Результат = ДлительныеОперации.ВыполнитьФункциюВНесколькоПотоков(
		"РасчетДокументовСервер.РассчитатьПорцию", ПараметрыВыполнения, НаборПараметров);

	Если Результат.Статус <> "Выполнено" Тогда
		ЗаписьЖурналаРегистрации(НСтр("ru = 'Расчет документов'"), УровеньЖурналаРегистрации.Ошибка, , ,
			Результат.ПодробноеПредставлениеОшибки);
		Возврат;
	КонецЕсли;

	// Далее - сбор результатов и запись, как в этапе 2

КонецПроцедуры

Возможные статусы: "Выполняется", "Выполнено", "Ошибка", "Отменено".

Сколько потоков и какого размера порции

Универсального числа нет, но есть порядок подбора, которым я пользуюсь:

  1. Замер на одном потоке. Запустите обработку с ЧислоПотоков = 1 на копии базы и запишите время. Это базовая линия.
  2. Удвоение. Пробуйте 2, 4, 8 потоков. Пока время падает почти пропорционально — узкого места нет.
  3. Точка насыщения. Когда рост числа потоков перестаёт сокращать время, смотрите, во что упёрлись: процессор рабочих процессов сервера 1С, диск или процессор СУБД, ожидания на управляемых блокировках.
  4. Запас для пользователей. Если обработка идёт днём, оставьте ресурсы для обычной работы: лучше 4 потока и спокойные пользователи, чем 12 потоков и жалобы.

Размер порции тоже важен. Слишком мелкие порции — много накладных расходов на запуск фоновых заданий и передачу параметров. Слишком крупные — один «тяжёлый» поток заканчивает позже всех, и общее время определяется им. Хорошее правило — порций столько же, сколько потоков, а если объекты сильно различаются по трудоёмкости, порций в 2–3 раза больше, чтобы нагрузка выровнялась.

Отладка многопоточного кода

Фоновые задания отлаживать неудобно, поэтому я строю код так, чтобы его можно было проверить без потоков:

  • метод обработки порции (РассчитатьПорцию) вызывается и напрямую, с маленьким массивом, — так его можно пройти отладчиком;
  • в БСП есть параметр выполнения, позволяющий запустить длительную операцию не в фоне (в версиях 3.1 — ЗапуститьНеВФоне), — удобно для отладки;
  • ошибки порций пишутся в журнал регистрации с ключом порции, чтобы по журналу было видно, какая часть данных упала;
  • для каждой порции фиксируется время выполнения — так видно перекос нагрузки.

Типичные ошибки

  • Пустой набор параметров. Порции подготовили, но не положили в соответствие — потоки не запустятся или результат будет пустым.
  • Неэкспортный или клиентский метод. Имя метода должно указывать на экспортную функцию серверного общего модуля.
  • Пересекающиеся порции при записи. Один документ в двух потоках — блокировки и «Данные были изменены другим пользователем».
  • Слишком много потоков. После некоторого числа рост останавливается, а ожидания на СУБД растут. Замеряйте.
  • Передача огромных объектов в параметрах. Параметры сериализуются; передавайте ссылки, а не объекты и не мегабайтные таблицы.
  • Файловая база. Ускорения не будет, механизм рассчитан на клиент-серверный режим.
Параллелить стоит не «всё подряд», а независимую работу. Правильное деление на порции даёт больше, чем удвоение числа потоков.

Если обработка всё равно медленная, причина часто в запросах и блокировках, а не в количестве потоков. Это разбираю на аудите производительности 1С; сами многопоточные обработки делаю в рамках доработки 1С.

Итог

  • Многопоточность БСП — это ДлительныеОперации.ВыполнитьФункциюВНесколькоПотоков и ВыполнитьПроцедуруВНесколькоПотоков.
  • Набор параметров — соответствие «ключ порции → массив параметров метода».
  • Результат — соответствие по ключам порций, у каждой свой статус и адрес результата.
  • Расчёт и запись разделяйте на два этапа, запись режьте по документам.
  • С клиента ждите через ДлительныеОперацииКлиент.ОжидатьЗавершение, на сервере — через ОжидатьЗавершение = Неопределено.