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

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

План

  1. Зачем мы говорили о play-json?
  2. Советы по написанию классификатора
  3. Поток, пулы потоков.
  4. Синхронизация, блокировки и атомики.
  5. Future и Promise.
  6. Реализация map, sequence и других функций стандартной библиотеки.
  7. for-нотация для Future.

Зачем мы говорили о play-json?

Работа с play-json - знание не слишком ценное:

  • Может не пригодиться
  • Если понадобится, легко разобраться по документации
  • Скорее всего устареет через пару лет

В прошлый раз мы изучали:

  • Implicit'ы и тайпклассы
  • Функциональный подход к сериализации
  • Функциональный подход к десериализации и валидации
  • Расширили знания о функторах
  • Получили базовое представление о JSON

Play-json для нас - только пример.

Советы по написанию классификатора

Почему нет подробной инструкции?

Работать с 100% известной средой – роскошь,
которая бывает редко.

Умение читать документацию, искать в google, stack overflow, т.п. – полезный навык для разработчика.

По этому вместо инструкций – изучаем теорию и причины выбора того или иного решения.

Как разобрать CSV?

  • "Руками" - разбираемся с файловым вводом выводом и работой со строками.
  • Ищем готовую библиотеку

Берем вот эту библиотеку:
scala-csv by Toshiyuki Takahashi

"Домашнее" задание: развиваем классификатор

  • Правки по code review
  • Добавляем нормализацию словоформ
  • Добавляем диагностику

Игнорируем окончания у слов.

Используем готовую библиотеку - Apache Lucene


libraryDependencies += 
  "org.apache.lucene" % "lucene-analyzers-common" % "7.2.1"
					

build.sbt

Создаем анализатор:


val analyzer = new RussianAnalyzer()
					

В комплекте - токенизатор и стеммер Портера

На нужно достать


case class Term(word: String, start: Int, end: Int)
					
val ts = analyzer.tokenStream("text", "тестовая строка")
ts.reset()

val out = new ArrayBuffer[Term]

while (ts.incrementToken()) {
  val word = 
    ts.getAttribute(classOf[CharTermAttribute]).toString

  val offsets = ts.getAttribute(classOf[OffsetAttribute])

  out += Term(word, offsets.startOffset(), offsets.endOffset())
}

out // Term(тестов,0,8), Term(строк,9,15) 

Добавляем диагностику

При классификации для каждого класса выбираем 3 характерных слова

Для итогового класса выделяем слова в тексте символами '*'

Пример:


вот вам английский язык! Выучить от сих до сих! Приеду — проверю! 
Если *не* выучите — моргалы *выколю*, пасти *порву* и, 
как их, эти… носы пооткушу. Ясно?!

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

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

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

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

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

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

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

thread.start()
					

Пулы потоков

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

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

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

  • под математику: число ядер
  • сетевое взаимодействие и простая логика без блокирующих вызовов: N * CPU, N ~= 3
  • дисковый ввод-вывод: число шпинделей жесткого диска
  • и т. п.
Верхняя граница у современных ОС - порядка 1000 потоков на процесс. Дальше не эффективно.
Пул потоков создается один раз

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

// создаем Scala-обертку
implicit val ec: ExecutionContextExecutor =
  ExecutionContext.fromExecutor(executor)
					

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


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

В пуле задачи помещаются в очередь, задачи из которой выполняются в потоках пула.

Синхронизация, блокировки и атомики.

Потокам нужно взаимодействовать: нужно координировать доступ к разделяемым ресурсам - памяти, сокетам, файлам и т.п.
Модель исполнения и памяти сложнее чем кажется:
  • Процессор, компилятор и JVM меняют реальный порядок выполения
  • Кеши процессоров сами не синхронизируются
  • Запись long/double не атомарна на некоторых платформах
Нужны специальные "барьеры" в точках взаимодействия.

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

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

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


val lock = new Object()

var counter: Int = 0

lock.synchronized {
  counter += 1
}					
					
В Java SDK есть много других вариантов: с поддержкой таймаута, latch, семафоры и т.п.

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

def readData(): Vector[Int] = {
  try {
    lock.readLock().tryLock(1, TimeUnit.MINUTES)

    data
  } finally {
    lock.readLock().unlock()
  }
}
					

Deadlock

Если брать lock'и в разном порядке в разных потоках можно получить взаимную блокировку, из которой не выйти.

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

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

volatile

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

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

Atomic

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

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

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

Atomic счетчик работает без блокировок:

// реализация из исходников JDK; Java
public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

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

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

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

Разделяемое состояние требует "протокола" доступа, который нужно разработать и потом ему следовать.

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

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

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

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

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

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

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

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

Сегодня об этом поговорим, продолжим на 8-м занятии.

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

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

Это подход "Акторов", о которых поговорим на 7-м занятии.

Future[T]

Future - результат отложенного вычисления.
Три базовых способа использования:
  • Подождать пока выполнится
  • Опросить состояние
  • Добавить callback
Ожидание выполнения

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 :-(")
}					
Добавляем 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}")
}

Promise[T]

Promise - контейнер для результата отложенного вычисления.
  • Значение можно сохранить один раз
  • Значение завершает связанную с ним Future

val p: Promise[Int]
val f: Future[Int] = p.future

f.onComplete {
  case Success(value) ⇒
    println(value)
  case Failure(ex) ⇒
    println(s"Failed: ${ex.toString}")
}

p.success(10)				
Есть несколько полезных готовых функций:
  • Future.apply - запуск функции в отдельном потоке
  • Future.successful - конструирование завершенной Future
  • Future.failed - конструирование завершенной с ошибкой 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 }
Используем Future.apply:

val f2: Future[Int] = Future {
  2 * 2
}

Трансформации Future

Работать с callback'ами плохо - когда их много код плохо читается и его тяжело отлаживать. Есть более удобные способы.

Future - функтор

Функция map

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

Законы функтора

композиция:


fa.map(f).map(g) = fa.map(x => g(f(x)))

identity


fa.map(x => x) = fa

Законы для программиста - это "контракт", который обязан реализовать автор функтора.

Функция map преобразует значение в Future, например:

val f3: Future[Int] = f.map(_ * 10)
функция будет выполнена тогда, когда
значение f будет вычислено
это может работать вот так:

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]): Future[S]
Пример цепочки трансформаций:

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

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

def userActive(user: Int): Boolean = 
  Set(1, 42).contains(user)

val count: Future[Int] = 
  userByEmail("user1@test")
    .filter(userActive)
    .flatMap(ticketsByUser)
    .map(_.length)

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


val count: Future[Int] = for {
  user <- userByEmail("user1@test") if userActive(user)
  tickets <- ticketsByUser(user)
} yield {
  tickets.length
}

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

  • transform[S](f: Try[T] => Try[S]): Future[S]
  • transformWith[S](f: Try[T] => Future[S]): Future[S]
Совет: избегайте в своем коде таких типов как Future[Future[...]], Future[Try[...]] и Try[Future[...]].
Это источник проблем.

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


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

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


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

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

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

Вспомним аппликативный функтор

Классическое определение - функция ap:

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

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

product - комбинирует два функтора
в функтор от пары


def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
					

Future - аппликативный функтор
(в библиотеке Cats)


import cats.instances.future._
import cats.syntax.all._

(getUserInfo(user), getUserInfo(user)).mapN(Result.apply)
					

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)
					

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

Дополнительно:
трансформер OptionT

Мы можем использовать for для Future[T] и для Option[T]

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

Что мы хотим?

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

Универсального способа объединить две монады нет.

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


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

Что в этом коде от Option?

Логика работы.

Что в этом коде от Future?

flatMap и функция создания

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

В библиотеке cats есть трансформер OptionT


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

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

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

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

Напоминаю: