Site icon AppTractor

Параллелизм в SQLite для Android

В документации SQLDelight есть такой пример:

val players: Flow<List<HockeyPlayer>> = 
  playerQueries.selectAll()
    .asFlow()
    .mapToList(Dispatchers.IO)

Это выглядит разумно, верно? В приложении Square Point Of Sale мы недавно обнаружили, что использование Dispatchers.IO для выполнения запросов SQLite может замедлить выполнение других задач приложения. Давайте разберемся во внутренностях SQLite для Android!

Одиночное соединение

В Android по умолчанию SQLite может открыть не более одного соединения с базой данных (источник: SQLiteConnectionPool.java).

Это означает, что одновременно SQL-запрос может выполнять только один поток.

Если вы запустите два SQLite-запроса к одной и той же базе данных параллельно с помощью Dispatchers.IO, один поток будет блокироваться, пока другой поток выполняет свой запрос, и ваше приложение потребит два потока из пула Dispatchers.IO. Первый поток будет удерживаться в течение всего времени выполнения первого запроса. Второй поток будет заблокирован на время выполнения первого + второго запросов, так как сначала он будет ждать, пока первый поток освободит соединение SQLite, прежде чем сможет его базу данных.

Параллелизм в SQLite для Android

Параллелизм Dispatchers.IO

Ну и в чем проблема? Ведь если есть максимум одно соединение, то логично, что все запросы должны выполняться один за другим.

Dispatchers.IO отправляет корутины не более чем на 64 потока параллельно (источник: Dispatcher.kt). По мере увеличения количества параллельно запущенных запросов потоки Dispatchers.IO будут задерживаться все дольше и дольше, проводя большую часть времени в блокировке и ожидании. В конце концов, все 63 из 64 потоков из пула Dispatchers.IO будут заблокированы в ожидании освобождения блокировки, а новые задачи Dispatchers.IO будут ожидать в очереди диспетчера.

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

На самом деле это распространенная проблема Dispatchers.IO, и в kdoc описано, как ее можно решить.

Эластичность для ограниченного параллелизма

Dispatchers.IO обладает уникальным свойством эластичности: его представления, полученные с помощью CoroutineDispatcher.limitedParallelism, не ограничены параллелизмом Dispatchers.IO. Концептуально существует диспетчер, работающий с неограниченным пулом потоков, и как Dispatchers.IO, так и представления Dispatchers.IO на самом деле являются представлениями этого диспетчера. На практике это означает, что, несмотря на несоблюдение ограничений параллелизма Dispatchers.IO, его представления разделяют с ним потоки и ресурсы. В следующем примере:

// 100 threads for MySQL connection
val myMysqlDbDispatcher = Dispatchers.IO.limitedParallelism(100)
// 60 threads for MongoDB connection
val myMongoDbDispatcher = Dispatchers.IO.limitedParallelism(60)

Во время пиковых нагрузок система может иметь до 64 + 100 + 60 потоков, предназначенных для блокирующих задач, но в устойчивом состоянии есть только небольшое количество потоков, распределенных между Dispatchers.IO, myMysqlDbDispatcher и myMongoDbDispatcher.

Поэтому, учитывая, что мы знаем, что можем выполнять не более одного запроса за раз, мы могли бы использовать отдельный экземпляр диспетчера Dispatchers.IO.limitedParallelism(1) для каждой базы данных, к которой мы обращаемся. Если бы у нас было N баз данных, мы бы использовали не более N + 64 потоков для блокирующих операций и никогда не опустошили бы пул потоков Dispatchers.IO.

Переключение потоков и кэш процессора

Хотя Dispatchers.IO.limitedParallelism(1) гарантирует, что наши запросы к базе данных SQLite будут выполняться в одном потоке, он не дает никаких гарантий относительно того, в каком именно потоке. Это означает, что каждый запрос может выполняться в разных потоках. Регуляторы CPU стараются, чтобы потоки по возможности придерживались одного ядра процессора, чтобы избежать постоянной перезагрузки кэша процессора. Поэтому при использовании Dispatchers.IO.limitedParallelism(1) мы имеем высокие шансы разрушить кэш процессора при каждом новом запросе. Вместо этого мы можем использовать диспетчер одного потока для каждой базы данных:

val playerDbDispatcher = newSingleThreadContext("player-db")
val players: Flow<List<HockeyPlayer>> = 
  playerQueries.selectAll()
    .asFlow()
    .mapToList(playerDbDispatcher)

WAL: больше соединений SQLite

В SQLite есть опция Write-Ahead Log (WAL), которая может привести к значительному повышению производительности и была включена по умолчанию в Android 9. WAL также поддерживает больший параллелизм (concurrency), однако по умолчанию в Android эта опция не включена, чтобы не сломать приложения, которые полагаются на предыдущее поведение: одно соединение приводит к последовательному выполнению операций, и некоторые приложения могут полагаться на это. Для поддержки дополнительного параллелизма в Android 11 добавлен метод SQLiteDatabase#enableWriteAheadLogging:

Этот метод позволяет параллельно выполнять запросы из нескольких потоков к одной и той же базе данных. Для этого открывается несколько соединений с базой данных и для каждого запроса используется отдельное соединение с базой данных. […] Хорошей идеей является включение write-ahead logging во всех случаях, когда к базе данных одновременно обращаются и изменяют ее несколько потоков. Однако write-ahead logging использует значительно больше памяти, чем обычное журналирование, поскольку к базе данных sa me устанавливается несколько соединений. Поэтому если база данных будет использоваться только одним потоком или если оптимизация параллелизма не очень важна, то логирование с опережением записи следует отключить.

После вызова SQLiteDatabase.enableWriteAheadLogging(true) база данных будет поддерживать одно первичное соединение и несколько вторичных.

Первичное соединение

Первичное соединение используется для запросов, которые пишут в базу данных, а также для любого запроса, выполняемого как часть транзакции (т. е. после SQLiteDatabase.beginTransaction() , источник: SQLiteDatabase.java).

Когда вы выполняете запрос, Android Framework автоматически определяет, является ли запрос запросом SELECT или запросом на запись (источник: SQLiteProgram.java). По-видимому, в старых версиях Android в разборе пользовательских запросов были ошибки, поэтому в библиотеке Room все запросы на запись автоматически оборачиваются в транзакцию, чтобы гарантировать, что они используют основное соединение. Рассмотрите возможность обертывания всех ваших запросов на запись в транзакцию.

Стоит ли вообще использовать SQLiteDatabase.beginTransaction() для запросов, предназначенных только для чтения?

Вторичные соединения

Вторичные соединения используются для выполнения запросов SELECT (источник: SQLiteProgram.java).

Вы можете прочитать R.integer.db_connection_pool_size во время выполнения с помощью следующего вспомогательного метода:

// Based on SQLiteGlobal.getWALConnectionPoolSize()
fun getWALConnectionPoolSize() {
  val resources = Resources.getSystem()
  val resId =
    resources.getIdentifier("db_connection_pool_size", "integer", "android")
  return if (resId != 0) {
    resources.getInteger(resId)
  } else {
    2
  }
}

Неисключительные транзакции

В документации SQLiteDatabase#enableWriteAheadLogging дается неожиданная рекомендация:

Для запуска записывающей транзакции следует использовать beginTransactionNonExclusive(). Неэксклюзивный режим позволяет файлу базы данных быть доступным для чтения другими потоками, выполняющими запросы.

На практике это означает, что beginTransaction() выполнит BEGIN EXCLUSIVE; а beginTransactionNonExclusive() выполнит BEGIN IMMEDIATE; (источник: SQLiteSession.java).

Однако в документации sqlite по транзакциям говорится следующее:

EXCLUSIVE и IMMEDIATE одинаковы в режиме WAL, но в других режимах журналирования EXCLUSIVE не позволяет другим соединениям базы данных читать базу данных, пока транзакция находится в процессе выполнения.

Я не уверен, почему документация рекомендует использовать beginTransactionNonExclusive(), когда включен режим WAL, так как это фактически то же самое, что beginTransaction(), это кажется ошибкой.

WAL и диспетчеры

Теперь мы можем использовать одновременное чтение и запись с помощью WAL, используя последовательный диспетчер для записи и одновременный диспетчер для чтения с соответствующей эластичностью, чтобы увеличить параллелизм без блокирования большего количества потоков, чем необходимо:

val dbWriteDispatcher = Dispatchers.IO.limitedParallelism(1)

val connectionPoolSize = getWALConnectionPoolSize()
val dbReadDispatcher = Dispatchers.IO.limitedParallelism(connectionPoolSize)

Или, если вы предпочитаете использовать выделенные пулы потоков:

val dbWriteDispatcher = newSingleThreadContext("$dbName-writes")

val connectionPoolSize = getWALConnectionPoolSize()
val dbReadDispatcher = newFixedThreadPoolContext(connectionPoolSize, "$dbName-reads")

При обоих предыдущих подходах мы получаем 5 потоков, выделенных для выполнения работы пула из 4 соединений, поэтому будут моменты, когда один поток будет заблокирован. Room применил другой, более оптимальный подход: использовать пул из 4 потоков как для чтения, так и для записи, но контролировать задачи записи, чтобы убедиться, что мы пытаемся выполнить только одну за раз, в последовательном порядке (источник: TransactionExecutor.kt).

Слон в комнате

Как все это применимо к Room?

Танец WAL в Room

При использовании Room, WAL с поддержкой одновременных соединений включен по умолчанию на API 16+, если только устройство не является устройством с низким уровнем оперативной памяти (источник: RoomDatabase.kt).

Room можно настроить на использование кастомных эксекюторов (RoomDatabase.Builder.setQueryExecutor() & RoomDatabase.Builder.setTransactionExecutor(), или RoomDatabase.Builder.setQueryCoroutineContext()).

В kdoc про RoomDatabase.Builder.setQueryExecutor говорится, что исполнитель запросов должен использовать максимальное количество потоков — без уточнения, сколько именно — и что исполнитель транзакций будет выполнять не более одного запроса за раз. Упоминание @Transaction в kdoc гласит: «Методы Insert, Update или Delete всегда выполняются внутри транзакции».

В документе также упоминается, что «если оба исполнителя транзакций и запросов не заданы, то будет использоваться исполнитель по умолчанию, который распределяет и делит потоки между библиотеками Architecture Components».

Таким исполнителем по умолчанию на самом деле является ArchTaskExecutor.getIOThreadExecutor() (источник: RoomDatabase.android.kt):

abstract class RoomDatabase {
  open class Builder<T : RoomDatabase> {
    open fun build(): T {
      if (queryExecutor == null && transactionExecutor == null) {
        transactionExecutor = ArchTaskExecutor.getIOThreadExecutor()
        queryExecutor = transactionExecutor
      }
      // ...

ArchTaskExecutor.getIOThreadExecutor() делегирует DefaultTaskExecutor.mDiskIO, который представляет собой фиксированный пул потоков размером 4 (источник: DefaultTaskExecutor.java). Этот пул потоков также используется в качестве планировщика выборки по умолчанию для Rx пейджинга (источник: RxPagedListBuilder.kt) и исполнителя по умолчанию для ComputableLiveData (источник: ComputableLiveData.kt).

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

Связанный SQLite

Стоит отметить, что Room теперь предлагает связанную версию SQLite вместо стандартной версии для Android. Это увеличивает размер APK (~1 МБ), но означает, что вы получаете последнюю версию SQLite, получаете точно такое же поведение на всех версиях Android и не используете код SQLite из Android Framework. Чтобы узнать больше, я рекомендую посмотреть Powering Room with Bundled SQLite.

Room с подключенным SQLite также предлагает надлежащую поддержку корутинов, то есть больше нет блокировки потоков в ожидании соединения с SQLite благодаря приостанавливающемуся пулу соединений (источник: ConnectionPoolImpl.kt). Это означает, что потоки блокируются только тогда, когда они действительно выполняют запрос, и снижается вероятность того, что пул потоков окажется заблокированным в ожидании соединения.

Ключевые выводы

Не могу не упомянуть, что ваши приложения для Android, вероятно, не должны пытаться параллельно выполнять миллиард SQL-запросов. У вас может возникнуть «проблема N + 1 запроса» (поищите!) или просто слишком много асинхронного кода.

Огромная благодарность Шейну Тангу за изучение документации по SQLite, Йигиту Бояру и Дани Сантьяго за то, что поделились тонной информации о внутреннем устройстве Room и SQLite, а также Заку Клиппенштейну и Джесси Уилсону за вычитку!

Источник

Exit mobile version