Проблемы многопоточности в Swift

При работе с многопоточностью перед вами всегда будут вставать вопросы: по какому принципу нужно переключаться между задачами и в каком порядке они должны выполняться? В этой статье мы поговорим о шести проблемах многопоточности: Race condition, Priority inversion, Deadlock, Livelock, Starvation и Data Race.

Race condition (Состояние гонки)

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

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

let queue = DispatchQueue(label: "Queue")
var value = 1

func changeValue() {
    sleep(1)
    value += 1
}

// 1. Изменим свойство в async
print(value)
queue.async {
    changeValue()
}
print(value)

// 2. Изменим свойство в sync
queue.sync {
    changeValue()
}
print(value)

После запуска этого кода мы увидим следующий вывод:

1
1
3

В данным примере для изменения свойства value мы используем функцию changeValue(). В первом случае — в асинхронном коде, во втором — в синхронном. На этом примере хорошо прослеживается так называемое состояние гонки, когда меняется результат из-за нарушения порядка событий.

Попробуем исправить ситуацию — вызываем функцию changeValue только синхронно:

let queue = DispatchQueue(label: "Queue")
var value = 1

func changeValue() {
    sleep(1)
    value += 1
}

// 1. Изменим свойство в sync
print(value)
queue.sync {
    changeValue()
}
print(value)

// 2. Изменим свойство в sync
queue.sync {
    changeValue()
}
print(value)
1
2
3

Теперь, вызывая метод changeValue синхронно мы блокируем текущий поток для выполнения последующих действий, избавляясь тем самым от race condition. Но ситуации могут быть гораздо сложней и не очевидней.

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

Priority inversion (Инверсия приоритетов)

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

Проблема многопоточности - Priority inversion

Предположим, что у нас существуют две задачи с низким (А) и высоким (Б) приоритетом — изображены по вертикали. В момент времени T1 задача (А) блокирует ресурс и начинает его использовать. В момент времени T2 задача (Б) вытесняет низкоприоритетную задачу (А) и пытается использовать ресурсом в момент времени T3. Но так как ресурс заблокирован, задача (Б) переводится в ожидание, а задача (А) продолжает выполнение. В момент времени Т4 задача (А) завершает использование ресурса и разблокирует его. Так как ресурс ожидает задача (Б), она тут же начинает выполнение.

Временной промежуток (T4-T3) называют инверсией приоритетов. В этом промежутке наблюдается несоответствие с правилами планирования — задача с более высоким приоритетом находится в ожидании в то время как низкоприоритетная задача выполняется.

Deadlock (Взаимная блокировка)

Это ситуация, когда несколько потоков блокируют друг друга.

Самый простой пример — вызов sync на главном потоке. Это сразу приводит к проблеме взаимной блокировки.

let queue = DispatchQueue(label: "label")
queue.sync { // 1
  queue.sync { // 2
    // deadlock
  }
}

Почему же в этом примере deadlock? Итак, очередь выполняет код синхронно. Второй запуск очереди не может быть запущен до тех пор, пока не завершится первый. Первый не может быть завершен до тех пор, пока не будет запущен второй, так как он вызывается синхронно.

Но это справедливо не только для главного потока. Рассмотрим последовательную очередь:

let queue = DispatchQueue(label: "label")
queue.async { // 1
    queue.sync { // 2
        // deadlock
    }
}

Здесь происходит взаимная блокировка потому, что когда мы выполняем код на третьей строчке, то мы это делаем в рамках уже выполняющейся в очереди задачи и пытаемся синхронно добавить задачу. Синхронно — означает, что мы должны дождаться завершения текущей задачи. Но мы не можем завершить задачу в которой уже находимся? (1) ждёт выполнения (2), а (2) ждёт завершения (1).

Livelock (Активная блокировка)

Livelock возникает тогда, когда программа выполняет параллельно несколько операций, но эти операции не продвигают программу к завершению. То есть потоки просто тратят свое время друг на друга, совершая, по факту, бесполезную работу.

Рассмотрим пример Livelock — пример с людьми в коридоре. Два человека встречаются лицом к лицу в узком коридоре и оба начинают шагать в одну сторону, чтобы пропустить другого. В итоге они так и не могут разойтись, потому что двигаются в одном направлении и в одно время.

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

public class People1 {
    var isDifferentDirections = false;
    
    public func walkPast(with people: People2) {
        while (!people.isDifferentDirections) {
            print("People1 не может обойти People2")
            sleep(1)
        }
        
        print("People1 смог пройти прямо")
        isDifferentDirections = true;
    }
}

public class People2 {
    var isDifferentDirections = false;
    
    public func walkPast(with people: People1) {
        
        while (!people.isDifferentDirections) {
            print("People2 не может обойти People1")
            sleep(1)
        }
        
        print("People2 смог пройти прямо")
        isDifferentDirections = true;
    }
}

var people1 = People1()
var people2 = People2()

let thread1 = Thread {
    people1.walkPast(with: people2)
}
thread1.start()


let thread2 = Thread {
    people2.walkPast(with: people1)
}
thread2.start()

Если запустить код, то можно увидеть следующий вывод:

People1 не может обойти People2
People2 не может обойти People1
People2 не может обойти People1
People1 не может обойти People2
.....

Starvation (Голодание)

Голодание — проблема, когда параллельный процесс не может получить ресурсы, необходимые для выполнения его работы.

Livelock считается подслучаем Starvation, так как параллельные процессы одинаково «голодают», и никакая работа не выполняется до конца.

Под голоданием обычно подразумевают когда не все, а один или несколько потоков мешают одному или нескольким другим потокам выполнять их работу так, как это задумывалось разработчиком (то есть наиболее эффективно).

Примером может служить некий метод, который занимает большое количество времени для исполнения. Если какой-нибудь один поток часто использует этот метод, то другие будут вынуждены находиться в блокировке.

public class SoundCloud {
    
    static func downloadSound() -> [String] {
        print("Обработка файлов")
        sleep(5) // Имитация тяжелого метода
        return ["SomeSound"]
    }
}

public class iPad {
    
    public func playSound() {
        print("Песня включилась")
    }
    
    public func shareSound() {
        print("Поделиться песней")
    }
}

var ipad = iPad()
let queue = DispatchQueue(label: "Starvation")

// Загрузить песню
queue.sync {
    SoundCloud.downloadSound()
}

// Включить песню
queue.async {
    ipad.playSound()
}

// Поделиться песней
queue.async {
    ipad.shareSound()
}

Из-за проблем с «тяжелым» методом downloadSound, который вызывается в синхронной очереди другие операции (play и share) вынуждены ожидать его завершения.

Data Race (Гонка за данными)

Data race — состояние, когда один поток обращается к изменяемому объекту, в то время как другой поток записывает в него.

var count = 0

let thread1 = Thread {
    for _ in 0...999 {
        count += 1
    }
}

let thread2 = Thread {
    for _ in 0...999 {
        count += 1
    }
}

thread1.start()
thread2.start()

Рассмотрим пример выше. Мы ожидаем результат count = 2000. Но, к сожалению, результат будет всегда меньше этого числа. Это происходит потому, что операция увеличения счетчика не атомарна — она состоит из нескольких шагов:

  • Чтение уже хранящегося значения;
  • Сложение результата с единицей;
  • Запись конечного результата обратно в переменную.

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

  • Поток 1 считывает текущее значение переменной и оно равно 0;
  • Поток 2 делает аналогичное действие и получает результат 0;
  • Далее поток 1 складывает результат с 1 и записывает обратно в переменную результат 1;
  • Далее поток 2 начинает свою работу — так как он уже считал, что результат равен 0, он проделывает аналогичную работу потоку 1 и записывает конечный результат тоже 1.

Для исправления подобного сценария можно синхронизировать потоки. Это можно сделать несколькими способами: NSLock, Semaphore, Serial queue… Рассмотрим пример на NSLock:

var count = 0
let lock = NSLock()

let thread1 = Thread {
    lock.lock()
    for _ in 0...999 {
        count += 1
    }
    lock.unlock()
}

let thread2 = Thread {
    lock.lock()
    for _ in 0...999 {
        count += 1
    }
    lock.unlock()
}

thread1.start()
thread2.start()

Если после завершения программы вывести переменную count, то она гарантированно будет равна 2000.

Дополнительная литература:

Выразить благодарность или найти уникальный материал вы можете в boosty.

Подписывайтесь на мой Telegram-канал iOS Interview Channel, чтобы не пропустить новый материал.


4 thoughts on “Проблемы многопоточности в Swift”

  1. Ребят, а докиньте пж примеры голодания и лайвлока как с остальными примерами. Желательно как с дедлоком — теория, пример, объяснение. Благодарю за материал.

    1. Отличное замечание, спасибо! На самом деле про взрывы потоков я слышал только один раз на собеседованиях. Вопрос интересный, хоть и нераспространенный. Думаю, под это можно и отдельный пост сделать 🙂

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *