sqlite3_unlock_notify() API 사용하기
sqlite3_unlock_notify() API 사용하기 (Using the sqlite3_unlock_notify() API)
공유 캐시 모드에서 두 개 이상의 연결이 같은 데이터베이스에 접근할 때, 테이블 잠금을 얻지 못하면 sqlite3_step()이나 sqlite3_prepare_v2()가 SQLITE_LOCKED를 반환해요. 이 문서는 sqlite3_unlock_notify() 인터페이스를 사용해 필요한 잠금이 사용 가능해질 때까지 즉시 SQLITE_LOCKED를 반환하는 대신 블로킹하는 기법(sqlite3_blocking_step(), sqlite3_blocking_prepare_v2())을 제시해요. 예제는 pthreads API를 사용해요.
출처: 문서
본문
/* This example uses the pthreads API */
#include <pthread.h>
/*
** A pointer to an instance of this structure is passed as the user-context
** pointer when registering for an unlock-notify callback.
*/
typedef struct UnlockNotification UnlockNotification;
struct UnlockNotification {
int fired; /* True after unlock event has occurred */
pthread_cond_t cond; /* Condition variable to wait on */
pthread_mutex_t mutex; /* Mutex to protect structure */
};
/*
** This function is an unlock-notify callback registered with SQLite.
*/
static void unlock_notify_cb(void **apArg, int nArg){
int i;
for(i=0; i<nArg; i++){
UnlockNotification *p = (UnlockNotification *)apArg[i];
pthread_mutex_lock(&p->mutex);
p->fired = 1;
pthread_cond_signal(&p->cond);
pthread_mutex_unlock(&p->mutex);
}
}
/*
** This function assumes that an SQLite API call (either sqlite3_prepare_v2()
** or sqlite3_step()) has just returned SQLITE_LOCKED. The argument is the
** associated database connection.
**
** This function calls sqlite3_unlock_notify() to register for an
** unlock-notify callback, then blocks until that callback is delivered
** and returns SQLITE_OK. The caller should then retry the failed operation.
**
** Or, if sqlite3_unlock_notify() indicates that to block would deadlock
** the system, then this function returns SQLITE_LOCKED immediately. In
** this case the caller should not retry the operation and should roll
** back the current transaction (if any).
*/
static int wait_for_unlock_notify(sqlite3 *db){
int rc;
UnlockNotification un;
/* Initialize the UnlockNotification structure. */
un.fired = 0;
pthread_mutex_init(&un.mutex, 0);
pthread_cond_init(&un.cond, 0);
/* Register for an unlock-notify callback. */
rc = sqlite3_unlock_notify(db, unlock_notify_cb, (void *)&un);
assert( rc==SQLITE_LOCKED || rc==SQLITE_OK );
/* The call to sqlite3_unlock_notify() always returns either SQLITE_LOCKED
** or SQLITE_OK.
**
** If SQLITE_LOCKED was returned, then the system is deadlocked. In this
** case this function needs to return SQLITE_LOCKED to the caller so
** that the current transaction can be rolled back. Otherwise, block
** until the unlock-notify callback is invoked, then return SQLITE_OK.
*/
if( rc==SQLITE_OK ){
pthread_mutex_lock(&un.mutex);
if( !un.fired ){
pthread_cond_wait(&un.cond, &un.mutex);
}
pthread_mutex_unlock(&un.mutex);
}
/* Destroy the mutex and condition variables. */
pthread_cond_destroy(&un.cond);
pthread_mutex_destroy(&un.mutex);
return rc;
}
/*
** This function is a wrapper around the SQLite function sqlite3_step().
** It functions in the same way as step(), except that if a required
** shared-cache lock cannot be obtained, this function may block waiting for
** the lock to become available. In this scenario the normal API step()
** function always returns SQLITE_LOCKED.
**
** If this function returns SQLITE_LOCKED, the caller should rollback
** the current transaction (if any) and try again later. Otherwise, the
** system may become deadlocked.
*/
int sqlite3_blocking_step(sqlite3_stmt *pStmt){
int rc;
while( SQLITE_LOCKED==(rc = sqlite3_step(pStmt)) ){
rc = wait_for_unlock_notify(sqlite3_db_handle(pStmt));
if( rc!=SQLITE_OK ) break;
sqlite3_reset(pStmt);
}
return rc;
}
/*
** This function is a wrapper around the SQLite function sqlite3_prepare_v2().
** It functions in the same way as prepare_v2(), except that if a required
** shared-cache lock cannot be obtained, this function may block waiting for
** the lock to become available. In this scenario the normal API prepare_v2()
** function always returns SQLITE_LOCKED.
**
** If this function returns SQLITE_LOCKED, the caller should rollback
** the current transaction (if any) and try again later. Otherwise, the
** system may become deadlocked.
*/
int sqlite3_blocking_prepare_v2(
sqlite3 *db, /* Database handle. */
const char *zSql, /* UTF-8 encoded SQL statement. */
int nSql, /* Length of zSql in bytes. */
sqlite3_stmt **ppStmt, /* OUT: A pointer to the prepared statement */
const char **pz /* OUT: End of parsed string */
){
int rc;
while( SQLITE_LOCKED==(rc = sqlite3_prepare_v2(db, zSql, nSql, ppStmt, pz)) ){
rc = wait_for_unlock_notify(db);
if( rc!=SQLITE_OK ) break;
}
return rc;
}
두 개 이상의 연결이 공유 캐시 모드에서 같은 데이터베이스에 접근할 때, 개별 테이블에 대한 읽기 및 쓰기(공유 및 배타) 잠금이 사용되어 동시에 실행되는 트랜잭션이 격리되도록 보장해요. 테이블에 쓰기 전에, 그 테이블에 쓰기(배타) 잠금을 얻어야 해요. 읽기 전에 읽기(공유) 잠금을 얻어야 해요. 연결은 트랜잭션을 마칠 때 보유한 모든 테이블 잠금을 해제해요. 연결이 필요한 잠금을 얻지 못하면, sqlite3_step() 호출이 SQLITE_LOCKED를 반환해요.
덜 흔하지만, sqlite3_prepare()나 sqlite3_prepare_v2() 호출도 각 ATTACH된 데이터베이스의 sqlite_schema 테이블에 대한 읽기 잠금을 얻지 못하면 SQLITE_LOCKED를 반환할 수 있어요. 이 API들은 SQL 문을 sqlite3_stmt* 객체로 컴파일하기 위해 sqlite_schema 테이블에 포함된 스키마 데이터를 읽어야 해요.
이 문서는 SQLite sqlite3_unlock_notify() 인터페이스를 사용해, sqlite3_step()과 sqlite3_prepare_v2() 호출이 즉시 SQLITE_LOCKED를 반환하는 대신 필요한 잠금이 사용 가능해질 때까지 블로킹하도록 하는 기법을 제시해요. 오른쪽에 보이는 sqlite3_blocking_step()이나 sqlite3_blocking_prepare_v2() 함수가 SQLITE_LOCKED를 반환하면, 이는 블로킹하면 시스템이 교착 상태가 된다는 것을 나타내요.
sqlite3_unlock_notify() API는 라이브러리가 전처리기 기호 SQLITE_ENABLE_UNLOCK_NOTIFY가 정의된 상태로 컴파일된 경우에만 사용할 수 있고, 여기에 문서화돼 있어요. 이 문서는 전체 API 문서를 읽는 것을 대체하지 않아요!
sqlite3_unlock_notify() 인터페이스는 각 데이터베이스 연결에 별도의 스레드가 할당된 시스템에서 사용하도록 설계됐어요. 구현에는 단일 스레드가 여러 데이터베이스 연결을 실행하는 것을 방해하는 것은 없어요. 하지만 sqlite3_unlock_notify() 인터페이스는 한 번에 단일 연결에서만 작동하므로, 여기 제시된 잠금 해결 로직은 스레드당 단일 데이터베이스 연결에 대해서만 작동해요.
sqlite3_unlock_notify() API
sqlite3_step()이나 sqlite3_prepare_v2() 호출이 SQLITE_LOCKED를 반환한 후, sqlite3_unlock_notify() API를 호출해 unlock-notify 콜백을 등록할 수 있어요. unlock-notify 콜백은 sqlite3_step()이나 sqlite3_prepare_v2() 호출이 성공하는 것을 막은 테이블 잠금을 보유한 데이터베이스 연결이 트랜잭션을 마치고 모든 잠금을 해제한 후 SQLite에 의해 호출돼요. 예를 들어 sqlite3_step() 호출이 테이블 X에서 읽으려는 시도이고, 다른 연결 Y가 테이블 X에 쓰기 잠금을 보유하고 있다면, sqlite3_step()은 SQLITE_LOCKED를 반환해요. 그런 다음 sqlite3_unlock_notify()가 호출되면, unlock-notify 콜백이 연결 Y의 트랜잭션이 끝난 후 호출될 거예요. unlock-notify 콜백이 기다리고 있는 연결(이 경우 연결 Y)을 "blocking connection(차단 연결)"이라고 해요.
데이터베이스 테이블에 쓰려는 sqlite3_step() 호출이 SQLITE_LOCKED를 반환하면, 두 개 이상의 다른 연결이 해당 데이터베이스 테이블에 읽기 잠금을 보유하고 있을 수 있어요. 이 경우 SQLite는 그 다른 연결 중 하나를 임의로 선택하고, 그 연결의 트랜잭션이 끝났을 때 unlock-notify 콜백을 발행해요. sqlite3_step() 호출이 하나 또는 여러 연결에 의해 차단됐는지 여부와 관계없이, 해당 unlock-notify 콜백이 발행될 때 필요한 잠금이 사용 가능하다는 보장은 없고, 단지 사용 가능할 수도 있다는 것뿐이에요.
unlock-notify 콜백이 발행될 때, 그것은 차단 연결과 관련된 sqlite3_step()(또는 sqlite3_close()) 호출 안에서 발행돼요. unlock-notify 콜백 안에서 어떤 sqlite3_XXX() API 함수를 호출하는 것은 불법이에요. 예상되는 사용은 unlock-notify 콜백이 다른 대기 중인 스레드에 신호를 보내거나 어떤 작업이 나중에 일어나도록 예약하는 거예요.
sqlite3_blocking_step() 함수가 사용하는 알고리즘은 다음과 같아요:
-
제공된 문 핸들에 sqlite3_step()을 호출해요. 호출이 SQLITE_LOCKED가 아닌 다른 것을 반환하면 이 값을 호출자에게 반환해요. 그렇지 않으면 계속해요.
-
제공된 문 핸들과 관련된 데이터베이스 연결 핸들에 sqlite3_unlock_notify()를 호출해 unlock-notify 콜백을 등록해요. unlock_notify() 호출이 SQLITE_LOCKED를 반환하면 이 값을 호출자에게 반환해요.
-
다른 스레드가 unlock-notify 콜백을 호출할 때까지 블로킹해요.
-
문 핸들에 sqlite3_reset()을 호출해요. SQLITE_LOCKED 오류는 sqlite3_step()의 첫 번째 호출에서만 발생할 수 있으므로(한 sqlite3_step() 호출이 SQLITE_ROW를 반환하고 다음이 SQLITE_LOCKED를 반환하는 것은 불가능), 이 시점에서 문 핸들을 리셋해도 호출자의 관점에서 쿼리 결과에 영향을 주지 않아요. 이 시점에 sqlite3_reset()을 호출하지 않으면, 다음 sqlite3_step() 호출이 SQLITE_MISUSE를 반환할 거예요.
-
1단계로 돌아가요.
sqlite3_blocking_prepare_v2() 함수가 사용하는 알고리즘은 4단계(문 핸들 리셋)가 생략된다는 점을 제외하면 유사해요.
Writer Starvation (작성자 기아)
여러 연결이 동시에 읽기 잠금을 보유할 수 있어요. 많은 스레드가 겹치는 읽기 잠금을 획득한다면, 적어도 하나의 스레드가 항상 읽기 잠금을 보유하는 경우가 있을 수 있어요. 그러면 쓰기 잠금을 기다리는 테이블이 영원히 기다리게 돼요. 이 시나리오를 "writer starvation"이라고 해요.
SQLite는 애플리케이션이 작성자 기아를 피하도록 도와줘요. 테이블에 쓰기 잠금을 얻으려는 어떤 시도가 실패한 후(하나 이상의 다른 연결이 읽기 잠금을 보유하고 있기 때문에), 다음 중 하나가 참이 될 때까지 공유 캐시에서 새 트랜잭션을 열려는 모든 시도가 실패해요:
- 현재 작성자가 트랜잭션을 마친다, 또는
- 공유 캐시에서 열린 읽기 트랜잭션의 수가 0으로 떨어진다.
새 읽기 트랜잭션을 열려는 실패한 시도는 호출자에게 SQLITE_LOCKED를 반환해요. 호출자가 그런 다음 sqlite3_unlock_notify()를 호출해 unlock-notify 콜백을 등록하면, 차단 연결은 현재 공유 캐시에 열린 쓰기 트랜잭션을 가진 연결이에요. 이는 작성자 기아를 방지해요. 새 읽기 트랜잭션이 열릴 수 없고 모든 기존 읽기 트랜잭션이 결국 끝난다고 가정하면, 작성자는 결국 필요한 쓰기 잠금을 얻을 기회를 가지게 되기 때문이에요.
pthreads API
wait_for_unlock_notify()가 sqlite3_unlock_notify()를 호출하는 시점에는, sqlite3_step()이나 sqlite3_prepare_v2() 호출이 성공하는 것을 막은 차단 연결이 이미 트랜잭션을 마쳤을 가능성이 있어요. 이 경우 unlock-notify 콜백은 sqlite3_unlock_notify()가 반환되기 전에 즉시 호출돼요. 또는, sqlite3_unlock_notify()가 호출된 후 스레드가 비동기 신호를 기다리기 시작하기 전에 두 번째 스레드가 unlock-notify 콜백을 호출할 수도 있어요.
이런 잠재적 경쟁 조건이 정확히 어떻게 처리되는지는 애플리케이션이 사용하는 스레드 및 동기화 기본 요소 인터페이스에 따라 달라져요. 이 예제는 Linux를 포함한 현대 UNIX 계열 시스템이 제공하는 인터페이스인 pthreads를 사용해요.
pthreads 인터페이스는 pthread_cond_wait() 함수를 제공해요. 이 함수는 호출자가 뮤텍스를 동시에 해제하고 비동기 신호를 기다리기 시작할 수 있게 해요. 이 함수, "fired" 플래그, 뮤텍스를 사용해 위에 설명된 경쟁 조건을 다음과 같이 제거할 수 있어요:
unlock-notify 콜백이 호출될 때 — 이는 sqlite3_unlock_notify()를 호출한 스레드가 비동기 신호를 기다리기 시작하기 전일 수도 있음 — 다음을 수행해요:
- 뮤텍스를 얻어요.
- "fired" 플래그를 true로 설정해요.
- 대기 중인 스레드에 신호를 보내려 시도해요.
- 뮤텍스를 해제해요.
wait_for_unlock_notify() 스레드가 unlock-notify 콜백이 도착하기를 기다리기 시작할 준비가 되면:
- 뮤텍스를 얻어요.
- "fired" 플래그가 설정되었는지 확인해요. 설정되어 있다면 unlock-notify 콜백이 이미 호출된 것이므로 뮤텍스를 해제하고 계속해요.
- 원자적으로 뮤텍스를 해제하고 비동기 신호를 기다리기 시작해요. 신호가 도착하면 계속해요.
이렇게 하면 wait_for_unlock_notify() 스레드가 블로킹을 시작할 때 unlock-notify 콜백이 이미 호출됐는지, 호출되고 있는 중인지는 중요하지 않아요.
가능한 개선 사항
이 문서의 코드는 적어도 두 가지 방식으로 개선될 수 있어요:
- 스레드 우선순위를 관리할 수 있어요.
- 테이블이나 인덱스를 드롭할 때 발생할 수 있는 SQLITE_LOCKED의 특수한 경우를 처리할 수 있어요.
sqlite3_unlock_notify() 함수가 호출자가 단일 사용자 컨텍스트 포인터만 지정할 수 있게 허용함에도, unlock-notify 콜백에는 그런 컨텍스트 포인터의 배열이 전달돼요. 이는 차단 연결이 트랜잭션을 마칠 때, 같은 C 함수를 호출하도록 등록된 unlock-notify가 둘 이상 있으면 컨텍스트 포인터가 배열로 마샬링되고 단일 콜백이 발행되기 때문이에요. 각 스레드에 우선순위가 할당된다면, 이 구현이 하듯이 스레드를 임의 순서로 신호하는 대신, 더 높은 우선순위의 스레드를 더 낮은 우선순위의 스레드보다 먼저 신호할 수 있어요.
"DROP TABLE"이나 "DROP INDEX" SQL 명령이 실행되고, 같은 데이터베이스 연결이 현재 하나 이상의 활발히 실행 중인 SELECT 문을 가지고 있으면, SQLITE_LOCKED가 반환돼요. 이 경우 sqlite3_unlock_notify()가 호출되면, 지정된 콜백이 즉시 호출될 거예요. "DROP TABLE"이나 "DROP INDEX" 문을 다시 시도하면 또 다른 SQLITE_LOCKED 오류가 반환될 거예요. 오른쪽에 보이는 sqlite3_blocking_step() 구현에서 이는 무한 루프를 일으킬 수 있어요.
호출자는 확장 오류 코드를 사용해 이 특수 "DROP TABLE|INDEX" 경우와 다른 경우를 구별할 수 있어요. sqlite3_unlock_notify()를 호출하는 것이 적절할 때, 확장 오류 코드는 SQLITE_LOCKED_SHAREDCACHE예요. 그 외에는 "DROP TABLE|INDEX" 경우에서 그저 단순한 SQLITE_LOCKED일 뿐이에요. 또 다른 해결책은 어떤 단일 쿼리가 재시도될 수 있는 횟수를 (100번이라고) 제한하는 것일 수도 있어요. 이는 원하는 것보다 덜 효율적일 수 있지만, 문제의 상황은 자주 발생하지 않을 가능성이 커요.
더 알아보기 (Learn more)
- sqlite3_unlock_notify() — C API — API 공식 문서
- Shared Cache Mode — 공유 캐시 모드
- SQLITE_ENABLE_UNLOCK_NOTIFY — 컴파일 타임 옵션
- pthread_cond_wait(3) — pthread 조건 변수