Что такое ExecutionContext и зачем он нужен?

В Scala ExecutionContext — это абстракция над пулом потоков, который используется для выполнения асинхронных задач, таких как Future. Он отвечает за то, где и как будет исполняться код, переданный в Future, map, flatMap, onComplete, recover и другие методы асинхронной обработки.

Без ExecutionContext невозможно запустить Future, потому что сама по себе конструкция Future ничего не делает до тех пор, пока вы не передадите в неё контекст, способный выполнить вложенный блок кода.

Пример использования

import scala.concurrent.{Future, ExecutionContext}
import scala.concurrent.ExecutionContext.Implicits.global
val f: Future\[Int\] = Future {
// Этот блок будет выполнен асинхронно в ExecutionContext
1 + 1
}

В данном примере используется глобальный ExecutionContext, предоставляемый через Implicits.global. Он основан на ForkJoinPool, адаптированном под асинхронные операции.

Зачем он нужен

  1. Разделение задач и исполнения: Future описывает что нужно сделать, а ExecutionContext — где и как. Это позволяет менять стратегию исполнения без изменения самой логики.

  2. Контроль над потоками: с помощью ExecutionContext можно указать, использовать ли общий пул потоков, выделенный, ограниченный, IO-ориентированный и т.д.

  3. Поддержка неблокирующей архитектуры: ExecutionContext позволяет эффективно использовать ограниченное число потоков для выполнения большого количества задач без блокировок.

Виды ExecutionContext

1. ExecutionContext.global

Это стандартный глобальный пул, основанный на ForkJoinPool, адаптирован для небольших и короткоживущих задач. Он создаётся автоматически:

import scala.concurrent.ExecutionContext.Implicits.global

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

2. Свой кастомный ExecutionContext

Для блокирующих задач нужно использовать выделенный пул потоков:

import java.util.concurrent.Executors
import scala.concurrent.ExecutionContext
val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(4))
Future {
// Блокирующая операция
Thread.sleep(1000)
}(ec)

Такой подход позволяет изолировать блокирующие операции от основного пула, не мешая другим Future.

Внутреннее устройство

Интерфейс ExecutionContext очень простой:

trait ExecutionContext {
def execute(runnable: Runnable): Unit
def reportFailure(t: Throwable): Unit
}
  • execute: передаёт задачу в очередь исполнения.

  • reportFailure: сообщает об ошибке, если она произошла вне Try.

Когда вы пишете:

Future { 1 + 1 }

На деле это означает:

ec.execute(new Runnable {
def run(): Unit = { ... }
})

Переопределение контекста для конкретной задачи

def runHeavyTask()(implicit ec: ExecutionContext): Future\[Int\] = Future {
Thread.sleep(1000)
42
}
val customEC = ExecutionContext.fromExecutor(Executors.newCachedThreadPool())
runHeavyTask()(customEC)

Так можно гибко переключаться между разными контекстами: например, для CPU-bound задач использовать один пул, для IO — другой.

Проблемы и ловушки

Блокирующий код в global

Future {
// Плохо!
Thread.sleep(5000)
}

global рассчитан на неблокирующий код. Если много Future будут блокироваться, вы истощите пул, и все остальные задачи остановятся.

Использование Await.result

В сочетании с ExecutionContext.global и Await.result можно легко получить дедлок:

val f = Future { Thread.sleep(1000); 42 }
Await.result(f, Duration.Inf) // потенциальная блокировка

Лучше избегать Await вообще — он блокирует поток и лишает смысла использование Future.

Как передать ExecutionContext явно

def compute()(implicit ec: ExecutionContext): Future\[Int\] = Future {
1 + 1
}

Это позволяет переиспользовать контекст и передавать его извне, а не полагаться на глобальный.

Как использовать разные ExecutionContext одновременно

val ioContext = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(10))
val cpuContext = ExecutionContext.fromExecutor(Executors.newWorkStealingPool())
val ioFuture = Future {
readFromFile()
}(ioContext)
val cpuFuture = ioFuture.map(data => process(data))(cpuContext)

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

Как работает в Akka

В рамках Akka, ExecutionContext обычно предоставляется ActorSystem.dispatcher, что гарантирует согласованность между исполнением акторов и обработкой Future:

implicit val system = ActorSystem("my-system")
implicit val ec = system.dispatcher

Связь с Future

Future невозможно запустить без ExecutionContext. Вот почему Scala требует его в имплиситах или явном виде:

Future { /\* что-то \*/ }(myContext)

Использование с блокирующими операциями: blocking

Если всё же нужно выполнить блокирующую операцию в global, можно использовать blocking:

import scala.concurrent.blocking
Future {
blocking {
Thread.sleep(1000)
"done"
}
}

Это сигнал ForkJoinPool увеличить число потоков временно, чтобы избежать "зависания".

Практическое правило

  • Используй global — только для быстрого, не блокирующего кода.

  • Для всего, что блокирует (файлы, базы, HTTP-клиенты) — делай отдельный ExecutionContext.

  • Всегда контролируй, где и как работают твои Future.