Что такое 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, адаптированном под асинхронные операции.
Зачем он нужен
-
Разделение задач и исполнения: Future описывает что нужно сделать, а ExecutionContext — где и как. Это позволяет менять стратегию исполнения без изменения самой логики.
-
Контроль над потоками: с помощью ExecutionContext можно указать, использовать ли общий пул потоков, выделенный, ограниченный, IO-ориентированный и т.д.
-
Поддержка неблокирующей архитектуры: 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.