Перейти к основному содержимому

РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ / REACT разработчик СЕВЕРСТАЛЬ IT & DIGITAL - Middle / Senior

· 17 мин. чтения

Сегодня мы разберем живое техническое собеседование на позицию фронтенд-разработчика, в котором акцент сделан на практическом применении TypeScript и работе с асинхронным JavaScript. В процессе интервью кандидат пошагово решает задачу по синхронизации изображений с сервером, обсуждает сложность алгоритма, оптимизацию через структуры данных и параллельную отправку запросов с ограничением по количеству.

Вопрос 1. Как в TypeScript написать type guard-функцию, которая принимает значение типа string | number и определяет, является ли оно строкой?

Таймкод: 00:01:31

Ответ собеседника: неправильный. Неуверенно описывает проверку через typeof, но не использует корректный синтаксис type guard в сигнатуре функции и не демонстрирует понимания механизма сужения типов.

Правильный ответ:

В TypeScript type guard — это специальная конструкция (функция или выражение), которая позволяет на уровне типов сузить объединение (union type) до более конкретного типа внутри условной ветки. Для пользовательских type guard-функций ключевым является возвращаемый тип с предикатом вида:

value is SomeType

Задача: функция, принимающая string | number и определяющая, является ли аргумент строкой.

Базовый и правильный пример:

function isString(value: string | number): value is string {
return typeof value === 'string';
}

Что здесь важно:

  1. Сигнатура type guard-а:

    • value: string | number — входной тип (union).
    • value is string — это не булево выражение, а type predicate.
    • TypeScript понимает: если isString(value) вернуло true, то в этой ветке value имеет тип string.
  2. Механизм сужения типа:

function printLengthOrDouble(value: string | number) {
if (isString(value)) {
// Здесь value: string
console.log(value.length);
} else {
// Здесь value: number
console.log(value * 2);
}
}

Благодаря предикату value is string компилятор умеет безопасно сузить тип без дополнительных утверждений (as string).

  1. Отличие от простой проверки typeof внутри вызывающего кода:
  • Если написать так:

    function isStringPrimitive(value: string | number): boolean {
    return typeof value === 'string';
    }

    TypeScript не использует возвращаемое boolean для сужения типа. В коде:

    if (isStringPrimitive(value)) {
    // Здесь value по-прежнему string | number, а не string
    }
  • Только предикат value is string в сигнатуре возвращаемого типа делает функцию полноценным type guard.

  1. Общая форма пользовательского type guard:
function isX(arg: unknown): arg is X {
// логика проверки типов/структуры
}

Это активно применяют для:

  • проверки сложных структур (объекты с полями),
  • работы с unknown и any,
  • безопасных runtime-проверок, которые синхронизированы с типовой системой.

Итого: корректный type guard должен использовать сигнатуру с type predicate (value is string), а не просто boolean, и обеспечивать консистентность runtime-проверки с описанным типом.

Вопрос 2. Что произойдет при вызове функции, принимающей строго строку, если значение передать через промежуточную функцию с типом any: будет ли ошибка типизации?

Таймкод: 00:10:35

Ответ собеседника: правильный. Правильно замечает, что при использовании типа any TypeScript не выдаст ошибок, так как any отключает типобезопасность.

Правильный ответ:

В TypeScript any фактически выключает систему типов для конкретного значения. Если у нас есть функция, принимающая строго строку, и мы "прокидываем" аргумент через сущность с типом any, TypeScript не выдаст ошибку типизации, даже если фактическое значение несовместимо с ожидаемым типом.

Рассмотрим пример:

function acceptsString(value: string) {
console.log(value.toUpperCase());
}

function wrapper(value: any) {
// Компилятор считает, что value: any, и позволяет передать его куда угодно
acceptsString(value);
}

wrapper(42); // На этапе компиляции: ОК. В рантайме: ошибка.

Ключевые моменты:

  1. Почему нет ошибки компиляции:

    • any говорит компилятору: "поверь мне, я знаю, что делаю".
    • Все проверки совместимости типов для значения any отключаются.
    • Вызов acceptsString(value) с value: any считается валидным.
  2. Что происходит в рантайме:

    • TypeScript не существует в рантайме, это чисто compile-time инструмент.
    • В примере выше при выполнении value.toUpperCase() для числа будет выброшено исключение: TypeError: value.toUpperCase is not a function.
  3. Почему это опасно:

    • any размывает границы типобезопасности и легко "протаскивает" ошибки в продуктив.
    • Ошибки, которые могли бы быть пойманы на этапе компиляции, превращаются в runtime-краши.
  4. Как делать правильно:

    • Избегать any, использовать unknown или более конкретные типы.
    • При использовании unknown компилятор требует явных проверок:
function wrapperSafe(value: unknown) {
if (typeof value === 'string') {
acceptsString(value); // здесь value: string
} else {
throw new Error('Expected string');
}
}

Таким образом:

  • При прокидывании через any ошибки типизации не будет.
  • Это ожидаемое, но потенциально опасное поведение, поэтому any нужно использовать осознанно и точечно.

Вопрос 3. В чем разница между типами unknown и any в TypeScript и какие проверки нужно выполнить перед использованием значения типа unknown?

Таймкод: 00:11:17

Ответ собеседника: неполный. Верно отмечает, что any не накладывает проверок, а unknown требует предварительных проверок типа, упоминает typeof, но не раскрывает, что для unknown необходимы явные сужения типа перед каждым использованием по назначению.

Правильный ответ:

Типы any и unknown — оба используются для значений, тип которых нам заранее не известен, но их семантика в системе типов принципиально различается.

Основные различия:

  1. any:

    • Полностью отключает систему типов для данного значения.
    • Можно:
      • вызывать любые методы,
      • обращаться к любым свойствам,
      • передавать куда угодно.
    • Компилятор почти ничего не проверяет.

    Пример (потенциально опасно):

    let value: any = 42;

    value.foo.bar(); // ОК для TypeScript, может упасть в рантайме
    value.toUpperCase(); // ОК для TS, упадет в рантайме

    any легко протаскивает ошибки в прод, поэтому его использование должно быть редким, точечным и контролируемым.

  2. unknown:

    • Это «безопасный any».
    • Значение может быть чем угодно, но:
      • Нельзя напрямую вызывать методы,
      • Нельзя обращаться к полям,
      • Нельзя использовать в местах, где ожидается конкретный тип,
      • Нельзя делать арифметику и т.п.
    • Пока вы явно не докажете (через проверки), что это более конкретный тип, компилятор не позволит использовать значение.

    Пример:

    let value: unknown = getValueFromSomewhere();

    // Ошибки компиляции:
    // value.toUpperCase();
    // value.trim();
    // value + 10;

    if (typeof value === 'string') {
    // Здесь value сужено до string
    console.log(value.toUpperCase());
    } else if (typeof value === 'number') {
    // Здесь value: number
    console.log(value + 10);
    }

Что значит «нужна проверка» для unknown:

Для использования unknown по назначению нужно выполнить явное сужение типа. Подходящие механизмы:

  • Проверка примитивов:
    • typeof value === 'string' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'undefined'
  • Проверка массивов и объектов:
    • Array.isArray(value)
    • value !== null && typeof value === 'object'
  • Проверка через оператор in:
    • if (typeof value === 'object' && value !== null && 'id' in value) { ... }
  • Пользовательские type guards (рекомендуется для сложных структур):
type User = {
id: number;
name: string;
};

function isUser(v: unknown): v is User {
return (
typeof v === 'object' &&
v !== null &&
'id' in v &&
'name' in v &&
typeof (v as any).id === 'number' &&
typeof (v as any).name === 'string'
);
}

function handleValue(v: unknown) {
if (isUser(v)) {
// Здесь v: User
console.log(v.id, v.name);
} else {
// Здесь v: не User
console.log('Not a user');
}
}

Ключевой принцип:

  • Для any: компилятор «верит на слово» и не защищает вас.
  • Для unknown: компилятор заставляет вас сначала доказать тип (через проверки/guards), и только потом использовать значение, что сильно повышает типобезопасность в больших кодовых базах.

Вопрос 4. Как оценить временную сложность алгоритма фильтрации картинок с вложенными проходами по массивам и можно ли её улучшить?

Таймкод: 00:40:01

Ответ собеседника: правильный. Определяет сложность как квадратичную из-за вложенных проходов по массивам и признает необходимость оптимизации для больших объемов данных.

Правильный ответ:

Рассмотрим典ичный сценарий: есть массив картинок и, например, массив тегов/условий/идентификаторов, по которым выполняется фильтрация. Если для каждой картинки мы итерируемся по другому массиву или делаем линейный поиск, то получаем вложенные циклы.

Упрощенный пример:

type Image struct {
ID int
Tags []string
}

// filter возвращает картинки, у которых есть хотя бы один тег из filterTags.
func filter(images []Image, filterTags []string) []Image {
res := make([]Image, 0)
for _, img := range images { // O(n)
matched := false
for _, tag := range img.Tags { // O(k)
for _, f := range filterTags { // O(m)
if tag == f {
matched = true
break
}
}
if matched {
break
}
}
if matched {
res = append(res, img)
}
}
return res
}

Оценка сложности:

  • n — количество картинок,
  • k — среднее число тегов у картинки,
  • m — количество фильтрующих тегов.

В худшем случае:

  • Внутренний поиск по filterTags — O(m),
  • Для всех тегов каждой картинки — O(k * m),
  • Для всех картинок — O(n * k * m).

Если рассматривать два уровня (например, просто for по картинкам и линейный поиск по списку ID/условий), то это O(n * m). Часто это называют квадратичной сложностью относительно размера входных наборов.

Для небольших объемов данных это может быть приемлемо, но для больших (десятки/сотни тысяч элементов) такая реализация становится бутылочным горлышком.

Как улучшить:

Ключевая идея — заменить повторяющийся линейный поиск на более эффективную структуру данных:

  1. Хеш-таблица/мапа для быстрых проверок принадлежности.

    • В Go — это map[KeyType]struct{} или map[KeyType]bool.
    • Временная сложность проверки принадлежности: амортизированно O(1).

    Пример оптимизации фильтрации по тегам:

    func filterOptimized(images []Image, filterTags []string) []Image {
    // Подготовка множества фильтров: O(m)
    filterSet := make(map[string]struct{}, len(filterTags))
    for _, t := range filterTags {
    filterSet[t] = struct{}{}
    }

    res := make([]Image, 0, len(images))
    for _, img := range images { // O(n)
    matched := false
    for _, tag := range img.Tags { // O(k)
    if _, ok := filterSet[tag]; ok {
    matched = true
    break
    }
    }
    if matched {
    res = append(res, img)
    }
    }
    return res
    }

    Сложность:

    • Построение множества: O(m)
    • Фильтрация: O(n * k) (проверка тега — O(1))
    • Итог: O(m + n * k), что асимптотически лучше O(n * k * m) или O(n * m).
  2. Если в исходной реализации был простой вложенный цикл по двум массивам (картинки и, скажем, список разрешенных ID), переход к map позволяет:

    • снизить сложность с O(n * m) до O(n + m),
    • что критично при росте данных.
  3. Возможные дополнительные оптимизации:

    • Предварительное индексирование картинок по ID, тегам, категориям:
      • map[id]Image
      • map[tag][]Image
    • Использование битмапов или bloom-фильтров для очень больших наборов.
    • Кеширование результатов фильтрации при повторяющихся запросах.

Важно:

  • Выявить, где именно происходит повторяющийся линейный поиск (вложенный цикл).
  • Вынести «медленный» поиск во внешнюю фазу построения индекса (map), сделать фильтрацию односкановой.
  • Следить за балансом: память vs скорость; для больших систем индексы — стандартный и оправданный инструмент.

Кратко:

  • Квадратичная (или умноженная) сложность из-за вложенных проходов по массивам — сигнал к оптимизации.
  • Применение map/индексов позволяет превратить вложенный поиск из O(n*m) в O(n+m), что критично для производительности на больших данных.

Вопрос 5. Как уменьшить сложность фильтрации картинок, используя подходящие структуры данных?

Таймкод: 00:43:08

Ответ собеседника: неполный. По подсказке вспоминает Set/Map и пытается применить их для оптимизации, но путается в логике и не формирует четкое, корректное решение.

Правильный ответ:

Основная цель — убрать вложенные линейные проходы и заменить их на операции с амортизированной сложностью O(1) с помощью хеш-структур (map/set), тем самым снизив общую сложность с O(n*m) до близкой к O(n + m).

Рассмотрим типичный кейс:
Есть список картинок и есть набор критериев (например, список разрешенных ID, URL, или тегов), по которым нужно быстро определить, какие картинки подходят.

Базовый (неэффективный) вариант:

type Image struct {
ID int
URL string
}

func filterSlow(images []Image, allowedIDs []int) []Image {
res := make([]Image, 0)
for _, img := range images { // O(n)
for _, id := range allowedIDs { // O(m)
if img.ID == id {
res = append(res, img)
break
}
}
}
return res
}
// Сложность: O(n * m)

Это плохо масштабируется при больших n и m.

Оптимизация через map (хеш-сет):

Идея: превратить список условий фильтрации в множество (set) для O(1)-проверки принадлежности.

В Go нет встроенного Set, но используется map[T]struct{} или map[T]bool:

func filterFast(images []Image, allowedIDs []int) []Image {
// Подготовка множества разрешенных ID: O(m)
allowed := make(map[int]struct{}, len(allowedIDs))
for _, id := range allowedIDs {
allowed[id] = struct{}{}
}

// Линейный проход по картинкам: O(n)
res := make([]Image, 0, len(images))
for _, img := range images {
if _, ok := allowed[img.ID]; ok {
res = append(res, img)
}
}
return res
}
// Итоговая сложность: O(m + n)
// Вместо O(n * m)

Аналогично, если фильтрация по URL:

func filterByURL(images []Image, allowedURLs []string) []Image {
urlSet := make(map[string]struct{}, len(allowedURLs))
for _, u := range allowedURLs {
urlSet[u] = struct{}{}
}

res := make([]Image, 0, len(images))
for _, img := range images {
if _, ok := urlSet[img.URL]; ok {
res = append(res, img)
}
}
return res
}

Фильтрация по тегам (сложнее, но принцип тот же):

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

type TaggedImage struct {
ID int
Tags []string
}

func filterByTags(images []TaggedImage, filterTags []string) []TaggedImage {
// Множество фильтрующих тегов: O(m)
tagSet := make(map[string]struct{}, len(filterTags))
for _, t := range filterTags {
tagSet[t] = struct{}{}
}

res := make([]TaggedImage, 0, len(images))
for _, img := range images { // O(n)
matched := false
for _, tag := range img.Tags { // O(k)
if _, ok := tagSet[tag]; ok {
matched = true
break
}
}
if matched {
res = append(res, img)
}
}
// Итог: O(m + n * k), без дополнительного умножения на m
return res
}

Дополнительный шаг: индексирование для многократных запросов

Если фильтрация выполняется часто, а набор картинок большой и относительно стабильный, имеет смысл построить индексы один раз.

Примеры индексов:

  • По ID:
type ImageIndex struct {
byID map[int]Image
}

func NewImageIndex(images []Image) *ImageIndex {
idx := &ImageIndex{byID: make(map[int]Image, len(images))}
for _, img := range images {
idx.byID[img.ID] = img
}
return idx
}

func (idx *ImageIndex) FilterByIDs(ids []int) []Image {
res := make([]Image, 0, len(ids))
for _, id := range ids {
if img, ok := idx.byID[id]; ok {
res = append(res, img)
}
}
return res
}
// Запросы по IDs теперь O(k), где k — количество искомых ID
  • По тегу:
type TagIndex struct {
byTag map[string][]Image
}

func NewTagIndex(images []Image) *TagIndex {
idx := &TagIndex{byTag: make(map[string][]Image)}
for _, img := range images {
for _, tag := range img.Tags {
idx.byTag[tag] = append(idx.byTag[tag], img)
}
}
return idx
}

func (idx *TagIndex) FilterByTags(tags []string) []Image {
// Можно объединять результаты, пересекать, дедуплицировать
seen := make(map[int]struct{})
res := make([]Image, 0)
for _, t := range tags {
for _, img := range idx.byTag[t] {
if _, ok := seen[img.ID]; !ok {
seen[img.ID] = struct{}{}
res = append(res, img)
}
}
}
return res
}

Ключевые выводы:

  • Использование any/линейного поиска по массиву условий — типичный источник O(n*m).
  • Правильный способ уменьшить сложность:
    • Преобразовать условия фильтрации в map/set.
    • Для частых запросов — строить индексы по ключевым полям (ID, URL, тегам).
  • В результате основная логика фильтрации становится линейной по входным данным (или лучше), что критично для систем с большими объемами изображений и высоким QPS.

Вопрос 6. Как правильно понимать задачу про очередь загрузки картинок: синхронная обработка с ограничением не более двух параллельных загрузок?

Таймкод: 00:49:10

Ответ собеседника: неполный. Пересказывает идею очереди и цикла, уточняет трактовку ограничения, но не формулирует корректный механизм контроля максимум двух одновременных загрузок.

Правильный ответ:

Задача формулируется так:

  • Есть список (очередь) URL картинок для загрузки.
  • Нужно:
    • обрабатывать их «по очереди» в смысле управляемого процесса, без хаотического создания неограниченного числа параллельных запросов;
    • при этом разрешается не более 2 одновременных загрузок в каждый момент времени;
    • как только одна загрузка завершилась — сразу можно запускать следующую из очереди, сохраняя лимит параллелизма.

Ключевая идея: это классический сценарий «пул воркеров» / «ограничение параллелизма»:

  • У нас есть:
    • очередь задач (URL картинок),
    • фиксированное число одновременных «рабочих» (2),
    • пока есть задачи — занятые воркеры загружают, освободившиеся берут новые.

Важно правильно понять термин «синхронная загрузка» в формулировке:

  • Обычно под этим в таком контексте имеют в виду управляемый, детерминированный процесс, а не реально блокирующие вызовы.
  • Фактически требуется:
    • асинхронная модель с ограниченным параллелизмом,
    • при этом весь список будет обработан, но не более 2 запросов одновременно.

Пример реализации в Go (ограничение до 2 параллельных загрузок):

Используем:

  • буферизированный канал как семафор,
  • WaitGroup для ожидания завершения всех загрузок.
package main

import (
"fmt"
"net/http"
"sync"
)

func downloadImage(url string) error {
// Пример: простая проверка доступности
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", resp.Status)
}

// Тут могла бы быть логика сохранения файла
return nil
}

func downloadWithLimit(urls []string, maxParallel int) {
if maxParallel <= 0 {
maxParallel = 1
}

sem := make(chan struct{}, maxParallel) // семафор параллелизма
var wg sync.WaitGroup

for _, url := range urls {
wg.Add(1)

// Блокируем, если уже запущено maxParallel задач
sem <- struct{}{}

go func(u string) {
defer wg.Done()
defer func() { <-sem }() // освобождаем "слот"

if err := downloadImage(u); err != nil {
fmt.Printf("failed to download %s: %v\n", u, err)
} else {
fmt.Printf("downloaded %s\n", u)
}
}(url)
}

wg.Wait()
}

func main() {
urls := []string{
"https://example.com/1.jpg",
"https://example.com/2.jpg",
"https://example.com/3.jpg",
"https://example.com/4.jpg",
}

// Не более двух одновременных загрузок
downloadWithLimit(urls, 2)
}

Как это работает:

  • sem := make(chan struct{}, maxParallel):
    • емкость канала = максимум одновременных задач;
    • каждая горутина при старте записывает в канал элемент (sem <- struct{}{}),
    • если канал заполнен — новая задача ждет, пока кто-то освободит слот.
  • defer func() { <-sem }():
    • по завершении загрузки освобождаем слот, позволяя следующей задаче стартовать.
  • В любой момент времени выполняется не более maxParallel загрузок (в задаче — 2).
  • Все URL обрабатываются до конца, порядок старта соответствует очереди, но завершение может быть в другом порядке — это нормально при ограниченном параллелизме.

Альтернативный вариант через пул воркеров:

  • Запускаем ровно 2 воркера (2 горутины),
  • Кладем все URL в канал задач,
  • Каждый воркер читает из канала и качает,
  • Когда задачи кончились — воркеры завершаются.

Семантически оба подхода корректны для условия: «есть очередь задач и не более двух загрузок одновременно».

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

  • Понимание отличия:
    • «последовательная загрузка» (строго одна за другой) vs.
    • «ограниченная параллельность» (до N одновременно).
  • Умение реализовать ограничение параллелизма:
    • через семафор (buffered channel),
    • через пул воркеров,
    • через токены или middleware на уровне HTTP-клиента.
  • Гарантия отсутствия «взрыва» горутин/запросов при большом количестве картинок.

Вопрос 7. Как реализовать отправку картинок с ограничением не более двух одновременных запросов, в том числе при нечётном количестве элементов?

Таймкод: 00:50:29

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

Правильный ответ:

Ключевая цель: гарантировать, что:

  • В каждый момент времени активно не более 2 запросов отправки картинок.
  • Все элементы (в том числе при нечётном количестве) будут обработаны.
  • Новая отправка начинается, как только освободилось место (завершился один из запросов).
  • Не нужно вручную «группировать по два» — это часто приводит к ошибкам в контроле параллелизма.

Оптимальное решение — использовать примитив, реализующий семафорный контроль параллелизма: буферизированный канал в Go.

Базовый паттерн (через семафор):

func sendImage(img string) error {
// Здесь логика отправки картинки (HTTP-запрос, запись в хранилище и т.д.)
// Это может быть синхронный вызов, блокирующий до завершения.
return nil
}

func sendImagesWithLimit(urls []string, maxParallel int) error {
if maxParallel <= 0 {
maxParallel = 1
}

sem := make(chan struct{}, maxParallel) // семафор для ограничения параллельности
var wg sync.WaitGroup

errs := make(chan error, len(urls))

for _, url := range urls {
wg.Add(1)

sem <- struct{}{} // блокируем, если уже запущено maxParallel горутин

go func(u string) {
defer wg.Done()
defer func() { <-sem }() // освобождаем слот по завершении

if err := sendImage(u); err != nil {
errs <- fmt.Errorf("failed to send %s: %w", u, err)
}
}(url)
}

wg.Wait()
close(errs)

// Опционально: агрегация ошибок
var resultErr error
for err := range errs {
if err != nil {
// В реальном коде лучше накапливать/логировать все ошибки
resultErr = err
}
}

return resultErr
}

Почему это решение корректно:

  • В любой момент времени в sem может лежать не более maxParallel элементов.
  • Перед запуском горутины мы делаем sem <- struct{}{}:
    • если уже запущено maxParallel горутин, следующая будет ждать, пока одна из них не завершится и не освободит слот.
  • По завершении отправки делаем <-sem:
    • это освобождает место для следующей задачи.
  • wg.Wait() гарантирует, что мы дождёмся завершения всех операций.
  • Лимит maxParallel = 2 выполняется строго для любого количества элементов:
    • при 1 элементе — 1 запрос;
    • при 2 элементах — максимум 2 параллельно;
    • при 3, 5, 101 элементах — всегда максимум 2 одновременно, без необходимости искусственно группировать по два.

Таким образом:

  • Не нужно вручную бить на пары и писать вложенные if для “последний нечётный”.
  • Семафорный подход автоматически корректно обрабатывает:
    • чётное и нечётное число элементов,
    • любые задержки: как только одна отправка завершилась — запускается следующая, не нарушая лимит.

Альтернатива: пул воркеров на 2 потока

Если хочется более явно выразить модель «2 воркера обслуживают очередь задач»:

func sendImagesWithWorkers(urls []string, workers int) error {
if workers <= 0 {
workers = 1
}

tasks := make(chan string)
errs := make(chan error, len(urls))

var wg sync.WaitGroup

// Стартуем фиксированное количество воркеров
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for url := range tasks {
if err := sendImage(url); err != nil {
errs <- err
}
}
}()
}

// Кладем задачи в очередь
go func() {
for _, url := range urls {
tasks <- url
}
close(tasks)
}()

wg.Wait()
close(errs)

var resultErr error
for err := range errs {
if err != nil {
resultErr = err
}
}

return resultErr
}

Свойства этого решения:

  • Ровно workers (2) одновременных обработчиков.
  • Каждый воркер берёт следующее задание из очереди.
  • Нечётное количество элементов обрабатывается естественным образом:
    • когда задачи заканчиваются, воркеры корректно завершаются.
    • ограничение “не более двух одновременно” сохраняется на всём протяжении обработки.

Типичные ошибки (как в неудачном ответе):

  • Группировка по два элемента и последовательный запуск без реального параллелизма.
  • Запуск горутин для всех картинок разом без ограничения (взрыв параллелизма).
  • Отсутствие ожидания завершения всех горутин (WaitGroup/каналы), что ведёт к раннему завершению функции.
  • Некакорректная обработка последнего (нечётного) элемента вручную, когда логика “пар” ломает общий контракт.

Правильный подход:

  • мыслить не «парами элементов», а «ограниченным пулом исполнителей»;
  • использовать семафор (buffered channel) или пул воркеров;
  • гарантировать строгий контроль верхнего лимита параллельных операций для любых размеров входных данных.