Часть 5. Базовые примитивы многопоточности

Страничка курса: https://maxcom.github.io/scala-course-2022/

План

  1. Асинхронность и многопоточность.
  2. Поток, пулы потоков.
  3. Синхронизация, блокировки и атомики.
  4. Future и Promise.
  5. Реализация функций работы с Future.
  6. Монадные трансформеры.
Первая часть лекции про low level и то, какие проблемы он нам несет.

Асинхронность и многопоточность.

В JVM есть и то, и другое. В других средах может быть что-то одно.

Потоки выполнения

  • Каждый поток выполняется процессором независимо.
  • Все потоки работают в общем адресном пространстве, но имеют свои стеки.
  • Процессор переключается между разными потоками.
  • В многоядерных системах потоки выполняются одновременно.
Thread в Java = поток в ОС
Потоки применяют для:
  • Выполнения программы более чем на одном ядре CPU.
  • Параллельного выполнения разных процессов программы.

В серверных проложениях часто выделяют по потоку каждому клиенту.

Программисту это удобно, но не всегда эффективно. В 7-й лекции поговорим об устройстве высоконагруженных приложений.

Асинхронность

Синхронная функция:


def func(input: String): String
					

Иногда работа не выполняется в нашей функции:

  • Запрос к БД выполняет БД
  • Сетевой I/O выполняет ядро
  • Работу делает другой поток
  • ...
Выполняем асинхронно - запускаем выполнение, которое само сигнализирует нам об его окончании.

Зачем?

  • Работа с большим числом сокетов
    рассмотрим на 7-й лекции
  • "Отзывчивость"
    гарантированное время отклика
  • Отказоустойчивость
    альтернативные ответы при ошибках, retry и пр
    рассмотрим на лекции про Akka

Асинхронная функция:


def func(input: String, onComplete: Try[String] => Unit): Unit
					

Проблема: не удобно и не ФП. Callback hell.

Еще бывает Cancellable

Работа с потоками

Создание потока - часть Java Runtime:

val thread = new Thread(() => {
  println("Hello world!")
})	

thread.start()
					

Явно потоки (почти) никогда не нужно создавать:

  • создание потока - "дорогая" операция
  • потоков не должно быть много

Сколько должно быть потоков?

Неизвестно - оценка "на глаз" + тестирование, но
  • под математику: число ядер
  • сетевое взаимодействие и простая логика без блокирующих вызовов: N * CPU, N ~= 3
  • дисковый ввод-вывод: число шпинделей жесткого диска. CPU не важно
  • и т. п.

Верхняя граница у современных ОС - порядка 1000 потоков на процесс. Дальше не эффективно.

Проблема: хотим управление потоками, не хотим работать с Thread напрямую.
Пул потоков создается один раз

// используем Java API
val executor: ExecutorService =
  Executors.newFixedThreadPool(10) // 10 потоков

// создаем Scala-обертку
// часто используют "implicit val"
val ec: ExecutionContext =
  ExecutionContext.fromExecutor(executor)
					

Задачи можно передать так:


ec.execute(() => {
  println("Hello world!")
})
					

Thread Pool = пул потоков + очередь задач

Стандартные реализации:

  • ForkJoinPool - хорошо распределяет мелкие задачи по CPU
  • ThreadPoolExecutor - для редко используемых или очень больших пулов
Проблема: мы не хотим работать с execute напрямую.
Проблема: потоки - источник недетерминированности:

def run(): Unit = for (i <- 0 to 10) println(i)

(0 to 5).foreach { _ =>
  ec.execute(run _)
}
					

разный порядок при каждом запуске - сложно обеспечить корректность и протестировать

Следующая проблема: доступ к общим ресурсам.

Например RandomAccessFile:

  • seek(offset)
  • read(...)

нельзя использовать из разных потоков

Большинство объектов и структур - не потокобезопасные.

Исключение: неизменяемые данные, но есть проблема их "публикации".

Общие ресурсы требуют последовательного доступа - пока один поток выполняется, другие его ждут.

Синхронизация из Java

Блокировка обеспечивает эксклюзивный доступ.

// наследие Java 1.0 - любой объект имеет lock
val lock = new Object()

// примитивные типы не имеют lock'ов
var counter: Int = 0

lock.synchronized {
  // инкремент - не атомарная операция
  counter += 1
}					
					

Синхронизация на this - плохо из-за его публичности

В JDK есть много других вариантов: с поддержкой таймаута, latch, семафоры и т.п.
Пример с тайм-аутом

val lock = new ReentrantLock()
var data = Vector(42)

def readData(): Vector[Int] = {
  try { // всегда оборачиваем в try/finally
    lock.tryLock(1, TimeUnit.MINUTES)

    data
  } finally {
    lock.unlock()
  }
}
					

Deadlock

Взаимная блокировка, из которой не выйти.

Пример: передача денег между счетами.

Dealock'ы бывают хитрые, но сводятся к той же схеме.

Пример: извлечение объектов парами из ограниченного пула.

Проблема: трудно обеспечить корректность локов, декомпозиция не работает.
Следующая проблема:
оптимизации в JVM и процессоре.

Модель исполнения и памяти сложнее, чем кажется:

  • Процессор, компилятор и JVM меняют реальный порядок выполнения
  • Кеши процессоров сами не синхронизируются на некоторых платформах
  • Запись long/double не атомарна на некоторых платформах

Модель памяти Java сложна - подробности смотрите Java Memory Model прагматика модели

Блокировки и synchronized создают барьер для оптимизаций

volatile

volatile переменная - создает барьер без блокировок.

// медленнее обычного var, но быстрее блокировки

@volatile 
var vcounter: Int = 0	
					
операции над ней "упорядочены", но защиты от "гонки потоков" нет

Atomic

Специальная инструкция процессора - CompareAndSet (CAS)

Меняет значение на новое, если старое равно заданному

Потокобезопасно

Пример - AtomicInteger

// метод класса java.util.concurrent.AtomicInteger
// реализация из исходников JDK; Java
public final int incrementAndGet() {
    for (;;) { // retry при конфликте
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}
дешевле lock, если логика простая

Такой подход может использоваться для списков, деревьев и хеш-таблиц.

Разделяемое изменяемое состояние + параллелизм = проблемы.

Нет хорошего способа расставить блокировки в сложной системе.

Блокировки требуют "протокола" использования. Язык и runtime его не верифицируют.

Декомпозиция не работает - проблемы возникают в момент интеграции.

Качественно расставить блокировки не всегда удается:

  • Например с GUI можно работать только из выделенного потока
  • Некоторые среды используют один лок на все вызовы
  • Контрпример - ядро Linux

Решение: разделяемое изменяемое состояние + параллелизм.

Делаем всю работу в одном потоке.

Пример: СУБД Redis, веб-сервер nginx, сервер приложений node.js

Решение: разделяемое изменяемое состояние + параллелизм.

Используем иммутабельные структуры и
цепочки обработки.

Пример: Future, Task, IO, Akka Streams

Решение: разделяемое изменяемое состояние + параллелизм.

Изменяемое состояние приватное, работаем с ним из одного потока.

Пример: акторы (Akka и др)

Конец первой части

Вторая часть - Future и Promise.
(+ монадные трансформеры)

Future[T]

Future - результат отложенного вычисления.

от асинхронной функции


def func(input: String, onComplete: Try[String] => Unit): Unit
					
переходим к

implicit val ec: ExecutionContext = ???
val f = def func(input: String): Future[String]
f.onComplete(...) // добавляем Callback
					

это базовый низкоуровневый механизм

пример callback

import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}

val f: Future[Int] = ???
implicit val ec: ExecutionContext = ???

f.onComplete { // выполняется в потоке ec
  case Success(value) ⇒
    println(value)
  case Failure(ex) ⇒
    println(s"Failed: ${ex.toString}")
}

всё еще не ФП

Еще есть два механизма
Ожидание выполнения

import scala.concurrent.{Await, Future}
import scala.concurrent.duration._

val f: Future[Int] = ???

val result: Int = Await.result(f, 5 minutes)
блокирует текущий поток до получения результата
Опрос состояния

import scala.concurrent.Future
import scala.util.{Failure, Success}

val f: Future[Int] = ???

f.value match {
  case Some(Success(value)) ⇒
    println(value)
  case Some(Failure(ex)) ⇒
    throw ex
  case None ⇒
    println("Not completed :-(")
}					

Promise[T]

Promise - контейнер для результата отложенного вычисления.

Нужен для построения асинхронных функций.

Promise = контейнер + связанная с ним Future

  • Значение можно сохранить один раз
  • Значение завершает связанную с ним Future
Future из асинхронной функции:

def func(input: String, onComplete: Try[String] => Unit): Unit

def futureFunc(input: String): Future[String] = {
  val p: Promise[String] = Promise()

  func(input, p.complete)

  p.future
}
Запускаем синхронную функцию в отдельном потоке:
def func(input: String): String = ???

// apply[T](body: => T)(implicit ec: ExecutionContext)
def futureFunc(input: String): Future[String] = Future {
  func(input)
}

Это удобная утилита, а не основное предназначение Future

Реализация Future.apply:
// реализация в библиотеке сложнее
def run[T](f :=> T)
          (implicit ec: ExecutionContext): Future[T] = {
  val p = Promise[T]()

  ec.execute(() ⇒ {
    p.complete(Try(f))
  })

  p.future
}

val f: Future[Int] = run { 2 * 2 }

перешли от execute к более функциональному стилю

Иногда хотим поместить готовое значение в Future:

  • Future.successful - завершенная Future
  • Future.failed - завершенная с ошибкой Future
  • Future.unit - Future, завершенная со значением Unit

(ExecutionContext не нужен, поток не требуется)

Callback - не удобно.

Future - функтор, у нее есть функция map

источник: Functors and Applicatives

(Подробнее о функторах на лекции про Cats)

Работаем со значением, которого еще нет:

val f3: Future[Int] = f.map(_ * 10)

// возвращается мгновенно, вычисляется когда f будет вычислено

похоже на работу с ленивыми структурами данных

это может работать вот так:
// pimp my library
implicit class MyFuture[T](val f: Future[T]) extends AnyVal {
  def myMap[R](func: T ⇒ R)
              (implicit ec: ExecutionContext): Future[R] = {
    val p = Promise[R]()

    f.onComplete {
      case Success(v)  ⇒ p.complete(Try(func(v)))
      case Failure(ex) ⇒ p.failure(ex)
    }

    p.future
  }
}

Future - монада

flatMap[S](f: T => Future[S])
          (implicit ec: ExecutionContext): Future[S]

f - монадическая функция

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

def userByEmail(email: String): Future[Int] = ???

def ticketsByUser(user: Int): Future[Seq[Int]] = ???

// где-то должен быть implicit ec
def countTickets(email: String): Future[Int] = 
  userByEmail(email).flatMap(ticketsByUser).map(_.length)
функция возвращается мгновенно

Можно использовать for:


// где-то должен быть implicit ec
val count: Future[Int] = for {
  user <- userByEmail("user1@test")
  tickets <- ticketsByUser(user)
} yield {
  tickets.length
}
аналог на callback'ах - "лапша", особенно после добавления обработки ошибок

Еще полезные методы

  • transform[S](f: Try[T] => Try[S]): Future[S]
  • transformWith[S](f: Try[T] => Future[S]): Future[S]

(аналог map/flatMap, но с обработкой ошибок)

Избегайте в коде таких типов как Future[Future[...]], Future[Try[...]] и Try[Future[...]].

Это источник проблем.

Сборка независимых Future


val res: Future[Result] = for {
  info  <- getUserInfo(user)
  stats <- getUserStat(user)
} yield Result(info, stats)

проблема - задержка вызова getUserStat

стартуем Future до for

val infoF = getUserInfo(user)
val statsF = getUserStat(user) 

val res: Future[Result] = for {
  info  <- infoF
  stats <- statsF
} yield Result(info, stats)

Монада - абстракция цепочки вычислений;
тут не подходит

Future это аппликативный функтор, но об это на лекции про Cats

Используем zip


val infoF = getUserInfo(user)

getUserInfo(user)
  .zip(getUserStat(user)) // Future[(info, stat)]
  .map(Result.tupled _)
					

Future.sequence для списков


def process(i: Int): Future[Int] = ???

val processed: Seq[Future[Int]] = 
  Seq(1, 2, 3).map(process) // тут начинается работа

val completed: Future[Seq[Int]] = 
  Future.sequence(processed) // тут собираем результат
					

sequence может быть опасен - загружает очередь пула потоков и может "перегрузить" process и пул

Что плохого в Future?

  • Запускается сразу
  • implicit ExecutionContext
  • Нет остановки/отмены; тайм-аутов
  • Нет управления ресурсами
  • Context switch при цепочках вычислений

Всё еще не ФП?


val x = Future(r.nextInt)
(x, x)

// vs

(Future(r.nextInt), Future(r.nextInt))
					

Монадные трансформеры

Что делать с Future[Option[T]]?

Пример map:


val maybeF = Future.successful(Some("value"))

maybeF.map(_.map(_.length))
					

Универсальный трансформер для функторов


import cats.data._
import cats.implicits._

// еще нужен implicit ec

val maybeF: Future[Option[String]] = ???

// заменяет maybeF.map(_.map(_.length))
Nested(maybeF).map(_.length).value 
// возвращает Future[Option[Int]]
					

Можно ли соединить две разные монады?

В общем случае нет.

Что мы хотим от Future[Option[T]]?

  • Асинхронное выполнение, как у Future
  • Выполнять операции, пока не возникнет None,
    как у Option. Или Failure, как у Future

можно сделать такой flatMap:


class FutureO[+A](val fut: Future[Option[A]]) extends AnyVal {
  def flatMap[B](f: A ⇒ FutureO[B])
                (implicit ec: ExecutionContext): FutureO[B] = {
    val newFuture = fut.flatMap {
      case Some(a) ⇒ f(a).fut
      case None    ⇒ Future.successful(None)
    }
    new FutureO(newFuture)
  }
}					
					

тут логика Option. От Future - flatMap и конструктор

На месте Future может быть любая другая монада.

Можно обобщить
(если у нас есть абстракция монады)

OptionT - создает монаду для M[Option[T]] (cats)


val greetingFO: Future[Option[String]] = ???
val firstnameF: Future[String] = ???
val lastnameO: Option[String] = ???

val ot = for { // OptionT[Future, String]
  g <- OptionT(greetingFO)
  f <- OptionT.liftF(firstnameF)
  l <- OptionT.fromOption[Future](lastnameO)
} yield s"$g $f $l"

val result: Future[Option[String]] = ot.value
					

Трансформеры есть и для некоторых других монад.

Напоминаю: