REA - SQLite

Repositório Educacional Aberto sobre a implementação de processos e threads no SQLite

O SQLite é uma biblioteca em linguagem C de código aberto que implementa um sistema de banco de dados SQL imbutido. Ao contrário de outros bancos de dados que ficam em servidores, cada banco de dados SQLite é armazenado em um único arquivo no disco. A biblioteca lê e escreve diretamente no arquivo de banco de dados no disco.

Por sua leveza e rapidez o SQLite é banco de dados interno oficial dos dispositivos Android sendo assim todo dispositivo possui o SQLite incorporado nativamente.

Este repositório contém informações sobre a implementação de Threads no SQLite, toda a informação aqui contida pode ser conferida no site oficial do SQLite assim como a documentação e o código fonte. No momento da elaboração deste repositório foi utilizada a versão 3.24.0 do SQLite.

</> Threading no SQLite

O SQLite implementa 3 modos de Threading:

  • Single thread: Modo onde todos mutex estão desabilitados e o SQLite não executa de forma segura em mais de uma thread de cada vez.

  • Multi-thread: Modo onde o SQLite pode ser utilizado com várias threads. Contudo uma conexão com o banco de dados não deve ser feita em mais que uma thread.

  • Serialized: SQLite pode utilizar várias threads sem restrição. Esse modo é utilizado como default.

O modo de implementação de thread pode ser selecionado no momento da compilação (compile-time), quando o aplicativo que utiliza o SQLite inicializa (start-time) ou quando a uma nova conexão com o SQLite está sendo criada (run-time).

Nessa linha de pensamento o run-time pode sobrescrever o start-time que por sua vez pode sobrescrever o compile-time. Quando compilado com a opção single thread as opções anteriores não podem ser sobrescritas.

</> Selecionando o modo de threading

"compile-time"

É configurado no momento da compilação pelo parâmetro de compilação SQLITE_THREADSAFE da seguinte forma:

  • -DSQLITE_THREADSAFE=0   /*Utilizado para setar o modo single-thread.*/

  • -DSQLITE_THREADSAFE=1   /*Utilizado para setar o modo serialized.*/

  • -DSQLITE_THREADSAFE=2   /*Utilizado para setar o modo multi-thread.*/

O retorno da função sqlite_threadsafe() é determinado pela seleção do modo de threading no momento da compilação, se selecionado o modo single-thread o retorno é FALSE caso seja selecionado o modo multi-thread ou serialized o retorno é TRUE.

Caso o modo de thread for selecionado na compilação como single thread, toda a lógica responsável pela exclusão mútua (mutex) é omitido do programa final tornando impossível a seleção posterior sendo via start-time ou run-time de outro modo de threading.

"start-time"

Assumindo que o modo não foi selecionado na hora da compilação como single-thread o modo de thread pode ser alterado durante a inicialização utilizando a função sqlite3_config() passando como parâmetro SQLITE_CONFIG_SINGLETHREAD, QLITE_CONFIG_MULTITHREAD ou SQLITE_CONFIG_SERIALIZED.

"run-time"

Assumindo agora que no compile-time e start-time o modo de threading não foi setado como single-thread então as conexões individuais do banco de dados pode ser escolhida entre multi-thread e serialized. Neste momento não é possível passar uma conexão de single-thread para multi-thread ou serialized.

O modo de threading para a conexão individual do banco de dados é determinado pelas flags passadas no terceiro argumento da função sqlite3_open_v2(), que podem ser passadas das formas:

  • SQLITE_OPEN_NOMUTEX   /*A conexão é setada para modo multi-thread.*/

  • SQLITE_OPEN_FULLMUTEX   /*A conexão é setada para modo serialized.*/

Se a flag não for especificada ou for utilizada as funções sqlite3_open() ou sqlite3_open16(), o modo de threading é definido pelo compile-time e start-time.

</> Funções Mutex

Para sincronização de threads o SQLite utiliza as seguintes rotinas:

  • sqlite3_mutex *sqlite3_mutex_alloc(int);

  • void sqlite3_mutex_free(sqlite3_mutex*);

  • void sqlite3_mutex_enter(sqlite3_mutex*);

  • int sqlite3_mutex_try(sqlite3_mutex*);

  • void sqlite3_mutex_leave(sqlite3_mutex*);

O SQLite possui múltiplas implementação dessas rotinas citadas, que são selecionadas no momento da compilação do código fonte, sendo elas:

  • SQLITE_MUTEX_PTHREADS  /*Utilizado em sistemas do tipo UNIX*/

  • SQLITE_MUTEX_W32  /*Utilizado em sistemas do tipo Windows*/

  • SQLITE_MUTEX_NOOP  /*Para utilização em single-thread*/

Nessa linha de pensamento o run-time pode sobrescrever o start-time que por sua vez pode sobrescrever o compile-time. Quando compilado com a opção single thread as opções anteriores não podem ser sobrescritas.

</> Rotina sqlite3_mutex_alloc()

Aloca em memória um novo mutex e retorna o ponteiro para sua posição, caso retorne NULL o mutex não pode ser alocado. Os argumentos são apresentados a seguir como constantes inteiras definidas no código fonte sendo elas:

  • SQLITE_MUTEX_FAST

  • SQLITE_MUTEX_RECURSIVE

  • SQLITE_MUTEX_STATIC_MASTER

  • SQLITE_MUTEX_STATIC_MEM

  • SQLITE_MUTEX_STATIC_OPEN

  • SQLITE_MUTEX_STATIC_PRNG

  • SQLITE_MUTEX_STATIC_LRU

  • SQLITE_MUTEX_STATIC_PMEM

  • SQLITE_MUTEX_STATIC_APP1

  • SQLITE_MUTEX_STATIC_APP2

  • SQLITE_MUTEX_STATIC_APP3

  • SQLITE_MUTEX_STATIC_VFS1

  • SQLITE_MUTEX_STATIC_VFS2

  • SQLITE_MUTEX_STATIC_VFS3

SQLITE_MUTEX_FAST e SQLITE_MUTEX_RECURSIVE quando passadas por parâmetro criam um novo mutex podendo ser do tipo FAST ou RECURSIVE. O SQLite apenas utiliza o modo RECURSIVE caso seja realmente necessário caso contrário ele preferivelmente utiliza o mutex do tipo FAST.

Os outro argumentos quando passados para o sqlite3_mutex_alloc() retornam um ponteiro para um mutex do tipo STATIC pré-existente. Um mutex estático pode ser manipulado apenas internamente pelo SQLite, para as aplicações que utilizam o SQLite apenas os mutex dinâmicos, retornados utilizando SQLITE_MUTEX_FAST e SQLITE_MUTEX_RECURSIVE, devem ser utilizados.

Código sqlite3_mutex *sqlite3_mutex_alloc()


SQLITE_API sqlite3_mutex *sqlite3_mutex_alloc(int id){
    ifndef SQLITE_OMIT_AUTOINIT
    if( id<=SQLITE_MUTEX_RECURSIVE && sqlite3_initialize() ) return 0;
    if( id>SQLITE_MUTEX_RECURSIVE && sqlite3MutexInit() ) return 0;
    #endif
    assert( sqlite3GlobalConfig.mutex.xMutexAlloc );
    return sqlite3GlobalConfig.mutex.xMutexAlloc(id);
}           
            
</> Procedimento sqlite3_mutex_free()

Desaloca um mutex alocado dinamicamente, caso houver uma tentativa de desalocar um mutex estático pode resultar em um comportamento indefinido.

Código sqlite3_mutex_free()


SQLITE_API void sqlite3_mutex_free(sqlite3_mutex *p){
  if( p ){
    assert( sqlite3GlobalConfig.mutex.xMutexFree );
    sqlite3GlobalConfig.mutex.xMutexFree(p);
  }
} 
              
</> Rotinas sqlite3_mutex_enter() e sqlite3_mutex_try()

Procedimentos que tentam acessar um mutex. Caso outra thread já esteja dentro do mutex o procedimento sqlite3_mutex_enter() irá bloquear e a função sqlite3_mutex_try() irá retornar SQLITE_BUSY. O sqlite3_mutex_try() retorna SQLITE_OK caso a entrada seja permitida.

Para o caso de mutex recursivo, SQLITE_MUTEX_RECURSIVE, uma única thread pode tentar acessar o mutex várias vezes, o mutex deve ser fechado esse mesmo número de vezes para que outra thread possa acessá-lo.

Código sqlite3_mutex_enter()


SQLITE_API void sqlite3_mutex_enter(sqlite3_mutex *p){
  if( p ){
    assert( sqlite3GlobalConfig.mutex.xMutexEnter );
    sqlite3GlobalConfig.mutex.xMutexEnter(p);
  }
}
              

Código sqlite3_mutex_try()


SQLITE_API int sqlite3_mutex_try(sqlite3_mutex *p){
  int rc = SQLITE_OK;
  if( p ){
    assert( sqlite3GlobalConfig.mutex.xMutexTry );
    return sqlite3GlobalConfig.mutex.xMutexTry(p);
  }
  return rc;
}
              
</> Procedimento sqlite3_mutex_leave()

Procedimento faz com que a thread saia do mutex que foi previamente inserida.

Código sqlite3_mutex_leave()


SQLITE_API void sqlite3_mutex_leave(sqlite3_mutex *p){
  if( p ){
    assert( sqlite3GlobalConfig.mutex.xMutexLeave );
    sqlite3GlobalConfig.mutex.xMutexLeave(p);
  }
} 
              

</> Condição de Disputa

Quando duas ou mais threads acessam a mesma base de dados em no modo memória compartilhada, chaves de escrita e leitura, tanto compartilhadas quanto exclusivas são usadas em tabelas individuais para garantir que execuções concorrentes se mantenham isoladas. Antes de uma operação de leitura, deve ser obtido uma chave de leitura compartilhada. Uma conexão libera todas as chaves que foram obtidas para uma tabela quando ela conclui suas operações. Se uma conexão não consegue obter uma chave, então uma chamada para sqlite3_step() retornará SQLITE_LOCKED.

Para chamadas de sqlite3_step() e sqlite3_prepare_v2() a uma referida chave não recebam SQLITE_LOCKED instantaneamente, mas sim bloqueiem até a referida chave estar disponível o SQLite utiliza sqlite3_unlock_notify().

Após uma chamada para sqlite3_step() ou sqlite3_prepare_v2() retornar SQLITE_LOCKED, sqlite3_unlock_notify() pode ser chamada para que quando a chave desejada esteja disponível, a conexão que a pediu receba uma notificação. Essa notificação ocorrerá quando a conexão que está fazendo uso das chaves que bloqueiam sqlite3_step() or sqlite3_prepare_v2() de obterem a chave, terminar suas requisições e liberar as chaves.

Quando uma notificação de disponibilidade de chave é emitida, esta é emitida de dentro de uma chamada para sqlite3_step() associada com a conexão que está bloqueando esta chave.

O algoritmo usado por sqlite3_blocking_step() funciona da seguinte forma:

  1. Chame sqlite3_step() no manipulador de intruções fornecido. Se esta chamada retornar qualquer coisa diferente de SQLITE_LOCKED então retorne este valor ao chamador, caso contrário continue.

  2. Chame sqlite3_unlock_notify () no identificador de conexão do banco de dados associado ao manipulador de instrução fornecido para registrar um retorno de chamada de notificação de desbloqueio. Se a chamada para unlock_notify () retornar SQLITE_LOCKED, retorne esse valor para o chamador.

  3. Bloqueie até que o retorno de chamada unlock-notify seja invocado por outra thread.

  4. Chame sqlite3_reset () no manipulador de instrução. Como um erro SQLITE_LOCKED só pode ocorrer na primeira chamada para sqlite3_step () (não é possível que uma chamada para sqlite3_step () retorne SQLITE_ROW e, em seguida, a próxima SQLITE_LOCKED), o manipulador de instrução pode ser reconfigurado neste momento sem afetar os resultados da consulta do ponto de vista do chamador. Se sqlite3_reset () não fosse chamado neste ponto, a próxima chamada para sqlite3_step () retornaria SQLITE_MISUSE.

  5. Retorne ao passo 1.

O algoritmo usado pela função sqlite3_blocking_prepare_v2 () é semelhante, exceto que a etapa 4 (reconfigurando o manipulador de instruções) é omitida.

</> WRITER STARVATION

Várias threads podem estar com uma chave de leitura simultâneamente. Se muitas threads estão com chaves de leitura sobrepostas, pode ocorrer que uma thread fique com uma chave de leitura para sempre, então uma tabela esperando para uma chave de escrita, esperará para sempre, este acontecimento é chamado de “writer starvation”.

Para prevenir tais acontecimentos todas as tentativas de obter novas chaves falham caso até que uma das condições ocorram:

  • A thread que está escrevendo termina sua operação.

  • O número de operações de leitura em shared-cache e zero.

Falhas na tentativa de abrir requisições de leitura retornam SQLITE_LOCKED para o chamador. Se este então chama sqlite3_unlock_notify() para requisitar uma notificação de desbloqueio de chave, a thread que está bloqueando é a thread que possui uma chave de escrita na cache-compartilhada.

</> Cache compartilhado

O “cache compartilhado” é destinado a servidores dedicados, quando o modo de cache compartilhado está ativo e uma thread estabelecer várias conexões para o mesmo banco de dados, as conexões compartilharão um único cache de esquema e de dados do banco. Isso pode reduzir significativamente a quantidade de memória e E/S exigidas pelo sistema. Na versão consequente o sqlite modificou o método de cache compartilhado, fazendo com que o mesmo cache possa ser compartilhado em todo um processo invés de apenas um único thread.

Do ponto de vista externa de outro processo ou thread, duas ou mais conexões ao banco de dados, por meio de cache compartilhada, aparecem como uma única conexão. O protocolo de bloqueio normal é usado para serializar o acesso ao banco de dados entre uma conexão e uma cache compartilhado, por exemplo.Existem três níveis para o modelo de bloqueio de cache compartilhado, bloqueio de nível de transação, bloqueio de nível de tabela e bloqueio de nível de esquema. Eles são descritos nas três subseções seguintes.

BLOQUEIO NO NÍVEL DE TRANSAÇÃO

Conexões SQLite podem abrir dois tipos de transações, ler e gravar. Isso não é feito explicitamente, uma transação de maneira implícita é uma transação de leitura até que a primeira grava em uma tabela de banco de dados, de forma em que se torna uma transação de gravação. No máximo, uma conexão com um único cache compartilhado pode abrir uma transação de gravação a qualquer momento. Isso pode coexistir com qualquer número de transações de leitura.

BLOQUEIO NO NÍVEL DE TABELA

Quando duas ou mais conexões usam um cache compartilhado, os bloqueios são usados ​​para serializar as tentativas de acesso simultâneo por tabela. As tabelas suportam dois tipos de bloqueios, "bloqueios de leitura" e "bloqueios de gravação". Os bloqueios são concedidos as conexões - a qualquer momento, cada conexão de banco de dados possui um bloqueio de leitura, um bloqueio de gravação ou nenhum bloqueio, isso dado em cada tabela do banco de dados.

A qualquer momento, uma única tabela pode ter qualquer número de bloqueios de leitura ativos ou um único bloqueio de gravação ativo. Para ler dados em uma tabela, uma conexão deve primeiro obter um bloqueio de leitura. Para gravar em uma tabela, uma conexão deve obter um bloqueio de gravação nessa tabela. Se um bloqueio de tabela necessário não puder ser obtido, a consulta falhará e SQLITE_LOCKED será retornado ao responsável pela chamada.

Quando uma conexão obtém um bloqueio de tabela, ela não é liberada até que a transação atual (leitura ou gravação) seja concluída.

BLOQUEIO NO NÍVEL DE ESQUEMA

A tabela sqlite_master suporta os bloqueios de leitura e gravação do cache compartilhado da mesma maneira que todas as outras tabelas do banco de dados. As seguintes regras especiais também se aplicam:

  • Uma conexão deve obter um bloqueio de leitura no sqlite_master antes de acessar qualquer tabela de banco de dados ou obter qualquer outro bloqueio de leitura ou gravação.

  • Antes de executar uma instrução que modifica o esquema do banco de dados (ou seja, uma instrução CREATE ou DROP TABLE), uma conexão deve obter um bloqueio de gravação em sqlite_master.

  • Uma conexão pode não compilar uma instrução SQL se qualquer outra conexão estiver mantendo um bloqueio de gravação na tabela sqlite_master de qualquer banco de dados anexado (incluindo o banco de dados padrão, "main").

Bruno Otávio Ramos

Acadêmico de Engenharia de Computação UEPG

Wellington Lima

Acadêmico de Engenharia de Computação UEPG

William Falinski

Acadêmico de Engenharia de Computação UEPG