namalzi

← 글 목록

MariaDB 소스 파헤치기 4: 연결과 스레드 — 연결마다 스레드 하나, 그리고 스레드 풀

MariaDB · 게시

mariadbd 의 메인 스레드가 poll() 로 접속을 받아 CONNECT 를 만들고, thread_handling 이 고른 스케줄러(함수 포인터 묶음)에 넘기기까지를 소스로 따라갑니다. 연결마다 스레드 하나를 두는 기본 모드와 스레드 캐시, THD 를 만들고 인증하는 로그인 단계, 그리고 스레드 그룹·리스너·워커·timer_thread 로 이루어진 스레드 풀이 놀고 있는 연결을 스레드에서 떼어 내는 방법을 ps·information_schema·gdb 실행 결과로 확인하고, httpd 의 MPM·Tomcat 의 엔드포인트와 비교합니다.

이 글에서 다루는 것

"MariaDB 소스 파헤치기" 연재의 네 번째 글입니다. 3편에서 mysqld_main() 이 플러그인과 엔진을 모두 초기화한 뒤 마지막으로 handle_connections_sockets() 의 poll() 루프에 들어가는 데까지 봤습니다. 이번 글은 그 루프에서 시작합니다. 2편 지도의 ① 연결 구역과, ② THD · 명령 구역 가운데 THD 를 만드는 앞부분을 확대합니다.

연재 지도에서 이 글의 위치. 연결, THD와 명령, 파싱, 준비, 최적화, 실행, handler 경계, 엔진, 그리고 실행에서 갈라지는 결과 보내기까지 아홉 칸 가운데 첫 칸인 연결과 둘째 칸인 THD·명령이 파랗게 칠해져 있다. 둘째 칸은 THD를 만드는 부분만 이 글이고 do_command 안쪽은 5편이다. 맨 위의 회색 상자 기동·플러그인(3편)은 끝에서 poll() 루프로 들어가며 연결 칸으로 이어진다. 파란 칸이 이 글이 다루는 곳입니다. 회색 칸은 뒤 글에서 봅니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 MariaDB Server
버전 13.2.0 (main, alpha)
커밋 c894d97781 (2026-10-19)
주로 읽는 파일 sql/mysqld.cc, sql/sql_connect.cc, sql/sql_connect.h, sql/scheduler.h·scheduler.cc, sql/thread_cache.h, sql/sql_acl.cc, sql/threadpool.h·threadpool_common.cc·threadpool_generic.h·threadpool_generic.cc, sql/thread_pool_info.cc

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

threadpool_generic.cc 하나가 1,770줄이고, 그 안에는 Linux 의 epoll 말고도 BSD 의 kqueue, Solaris 의 이벤트 포트, Windows 의 IOCP 판이 함께 들어 있습니다. 인증(sql_acl.cc)은 17,000줄이 넘습니다. 이 글은 처음 서버 소스를 여는 분이 따라올 수 있는 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

CONNECT: 메인 스레드가 accept() 로 소켓 하나를 받자마자 만드는 작은 객체입니다(sql/sql_connect.h 32행). 소켓, 소켓 종류(TCP 인지 유닉스 소켓인지), 이 연결을 맡을 스케줄러, 그리고 곧 붙을 연결 번호(thread_id)만 들고 있습니다. "아직 THD 가 없는 연결"이라고 보면 됩니다. 연결을 맡은 스레드가 THD 를 만들고 나면 지웁니다.

THD(thread descriptor): 연결 하나의 모든 상태를 담는 서버의 가장 큰 객체입니다(sql/sql_class.h 의 class THD). 세션 변수, 현재 문장의 LEX, 열어 둔 테이블, 트랜잭션, 네트워크 버퍼(NET)가 다 여기 있습니다. 이름에 "thread" 가 들어 있지만 스레드가 아니라 연결에 붙은 객체라는 것이 이 글의 중요한 대목입니다. 스레드 풀에서는 THD 하나가 여러 스레드를 옮겨 다닙니다. THD 의 전체 모양은 2편에서 봤습니다.

스케줄러(scheduler_functions): "새 연결이 오면 어느 스레드에 맡길까", "연결이 무언가를 기다리기 시작하면 누구에게 알릴까"를 정하는 함수 포인터 묶음입니다. C++ 로 쓰였지만 가상 함수가 아니라 C 식의 구조체 + 함수 포인터입니다. httpd 의 MPM 이 하는 일을 이 구조체 하나가 맡습니다(httpd 8편).

thread_handling: 어떤 스케줄러를 쓸지 고르는 시스템 변수입니다. 값은 one-thread-per-connection(연결마다 스레드 하나), no-threads(메인 스레드가 직접 처리), pool-of-threads(스레드 풀) 셋이고, 기동할 때만 정할 수 있습니다(읽기 전용).

스레드 캐시: 연결이 끊긴 뒤 스레드를 바로 없애지 않고 잠시 재워 두었다가 다음 연결에 다시 쓰는 장치입니다(sql/thread_cache.h 의 class Thread_cache). 크기는 thread_cache_size 입니다.

스레드 풀의 그룹·리스너·워커: 스레드 풀은 연결들을 몇 개의 그룹으로 나눕니다(thread_pool_size 개). 그룹마다 epoll 하나와 작업 큐가 있고, 그룹의 스레드(워커) 가운데 하나가 리스너가 되어 그룹의 모든 연결 소켓을 epoll 로 지켜봅니다. 6절에서 자세히 봅니다.

epoll: 소켓 여러 개를 등록해 두고 "읽을 것이 생긴 소켓을 알려 달라"고 Linux 커널에 묻는 기능입니다. 스레드 하나가 소켓 수천 개를 지켜볼 수 있습니다. httpd 의 event MPM, Tomcat 의 Poller 가 쓰는 것과 같은 운영체제 기능입니다.

1. 메인 스레드 — poll() 하고, accept() 하고, CONNECT 를 만든다

접속 하나가 연결 스레드에 닿기까지의 순서도. 클라이언트가 connect하면 메인 스레드의 poll이 깨어나 accept하고, handle_accepted_socket이 CONNECT를 만들고, create_new_thread가 연결 수를 검사하고 thread_id를 붙인 뒤 스케줄러의 add_connection을 부른다. 캐시에 쉬는 스레드가 있으면 thread_cache.enqueue로 깨우고, 없으면 mysql_thread_create로 새로 만든다. 메인 스레드는 다시 poll로 돌아가고, 연결 스레드가 create_thd로 THD를 만든 뒤 핸드셰이크와 인증을 하고 do_command를 되풀이한다. 메인 스레드는 소켓을 받아 CONNECT 로 감싸 넘길 뿐, 클라이언트와 대화하지 않습니다(연결 수가 넘쳐 오류 패킷 하나를 보내고 닫는 경우만 예외입니다 — 예제 2). 핸드셰이크부터는 연결 스레드의 일입니다.

poll() 루프

3편에서 본 mysqld_main() 의 끝은 run_main_loop() 이고, 그 안이 handle_connections_sockets() 하나입니다. 듣기 소켓(TCP 3306, 유닉스 소켓, 설정했다면 extra_port)을 모두 pollfd 배열에 넣고, 종료를 알리는 파이프 하나를 더 넣은 뒤 이렇게 돕니다.

  while (!abort_loop)
  {
    retval= poll(fds.get_pos(0), fds.size(), -1);
    // ...
    for (uint retry=0; retry < MAX_ACCEPT_RETRY && !abort_loop; retry++)
    {
      size_socket length= sizeof(struct sockaddr_storage);
      MYSQL_SOCKET new_sock;

      new_sock= mysql_socket_accept(key_socket_client_connection, sock,
                                    (struct sockaddr *)(&cAddr),
                                    &length);
      if (mysql_socket_getfd(new_sock) != INVALID_SOCKET)
        handle_accepted_socket(new_sock, sock);
      else if (socket_errno == SOCKET_EAGAIN || socket_errno == SOCKET_EWOULDBLOCK)
        break;

sql/mysqld.cc 6888–6932행

poll() 의 마지막 인자 -1 은 "무한히 기다린다"입니다. 깨어나면 어느 듣기 소켓이 읽을 수 있게 됐는지 찾고(생략한 6911–6919행), accept() 를 EAGAIN 이 날 때까지 되풀이합니다. 듣기 소켓은 루프에 들어오기 전에 논블로킹으로 바꿔 두었기 때문에(set_non_blocking_if_supported(), 6863행) 줄 선 접속을 다 받고 나면 accept() 가 기다리지 않고 EAGAIN 을 돌려줍니다. 한 번 깨어날 때 몰려온 접속을 한꺼번에 받는 구조입니다.

handle_accepted_socket() — CONNECT 만들기

받은 소켓은 handle_accepted_socket() 로 갑니다. TCP wrapper(HAVE_LIBWRAP) 검사를 빼면 하는 일은 한 문장입니다.

  if (auto connect= new CONNECT(new_sock,
                                sock.is_unix_domain_socket ?
                                VIO_TYPE_SOCKET : VIO_TYPE_TCPIP,
                                sock.is_extra_port ?
                                extra_thread_scheduler : thread_scheduler))
    create_new_thread(connect);

sql/mysqld.cc 6813–6818행

여기서 스케줄러가 연결마다 정해진다는 것을 눈여겨봅니다. 보통 포트로 들어온 연결은 thread_scheduler, extra_port 로 들어온 연결은 extra_thread_scheduler 를 받습니다. 2절에서 보듯 둘은 서로 다른 모드일 수 있습니다.

create_new_thread() — 연결 수 검사와 연결 번호

  if ((*connect->scheduler->connection_count)++ >=
      *connect->scheduler->max_connections + 1)
  {
    DBUG_PRINT("error",("Too many connections"));
    connect->close_with_error(0, NullS, ER_CON_COUNT_ERROR);
    DBUG_VOID_RETURN;
  }

  uint sum= connection_count + extra_connection_count;
  if (sum > max_used_connections)
  {
    max_used_connections= sum;
    max_used_connections_time= time(nullptr);
  }

  // ... (임베디드 라이브러리에 대한 주석 생략)
  connect->thread_id= next_thread_id();
  connect->scheduler->add_connection(connect);

sql/mysqld.cc 6735–6756행

세 가지가 일어납니다.

  1. 연결 수를 하나 올리면서 검사합니다. 올리기 전 값이 max_connections + 1 이상이면 거절합니다. 곧 이 자리에서는 max_connections 보다 하나 더 들어올 수 있습니다. 이 한 자리가 누구 몫인지는 4절의 인증에서 정해집니다.
  2. Max_used_connections 상태 변수를 갱신합니다.
  3. 연결 번호를 붙입니다. next_thread_id() 가 주는 이 값이 SHOW PROCESSLIST 의 Id, CONNECTION_ID(), KILL <id> 의 그 번호입니다. 이름은 thread_id 이지만 실제로는 연결 번호입니다.

그리고 마지막 줄에서 연결을 스케줄러의 함수 포인터 add_connection 에 넘깁니다. 메인 스레드의 일은 여기까지이고, 곧바로 poll() 로 돌아갑니다. 연결 수는 연결이 끝날 때 unlink_thd() 가 되돌려 놓습니다(--*thd->scheduler->connection_count;, 3096행). SHOW STATUS 의 Threads_connected 는 이 connection_count 를 그대로 보여 줍니다(8218행). 이름과 달리 스레드가 아니라 연결의 수입니다.

예제 1: 접속을 받는 길과 로그인하는 길을 gdb 로

정말 이 순서인지 실행 중인 서버에 gdb 를 붙여 확인했습니다. 이 예제는 기준 커밋(c894d97781)을 그대로 Debug 빌드한 MariaDB 13.2.0(13.2.0-MariaDB-debug)으로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 예제 폴더는 ex/u04-gdb 이고, 이 부분의 핵심은 이렇습니다.

# 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
# ...
lab-start > /dev/null
gdb -p "$(cat /tmp/mariadbd.pid)" -batch \
    -ex 'break create_thread_to_handle_connection' -ex 'break acl_authenticate' \
    -ex 'continue' -ex 'bt' -ex 'continue' -ex 'bt' -ex 'detach' > /tmp/bp.txt 2>/dev/null & GDB=$!
sleep 5
mariadb -e "SELECT 'hello' AS greeting" > /dev/null
# ...

[예제 ex/u04-gdb/run.sh 에서 줄임]

중단점 두 곳에서 찍힌 백트레이스입니다(인자 값과 주소는 지웠습니다).

=== 0. breakpoints: who accepts, who logs in
#0  create_thread_to_handle_connection at /usr/src/mariadb/sql/mysqld.cc:6682
#1  create_new_thread at /usr/src/mariadb/sql/mysqld.cc:6756
#2  handle_accepted_socket at /usr/src/mariadb/sql/mysqld.cc:6818
#3  handle_connections_sockets at /usr/src/mariadb/sql/mysqld.cc:6930
#4  run_main_loop at /usr/src/mariadb/sql/mysqld.cc:6171
#5  mysqld_main at /usr/src/mariadb/sql/mysqld.cc:6590
#6  main at /usr/src/mariadb/sql/main.cc:34

#0  acl_authenticate at /usr/src/mariadb/sql/sql_acl.cc:17039
#1  check_connection at /usr/src/mariadb/sql/sql_connect.cc:1184
#2  login_connection at /usr/src/mariadb/sql/sql_connect.cc:1252
#3  thd_prepare_connection at /usr/src/mariadb/sql/sql_connect.cc:1440
#4  do_handle_one_connection at /usr/src/mariadb/sql/sql_connect.cc:1504
#5  handle_one_connection at /usr/src/mariadb/sql/sql_connect.cc:1426
#6  pfs_spawn_thread at /usr/src/mariadb/storage/perfschema/pfs.cc:2198
#7  ?? () from /lib/x86_64-linux-gnu/libc.so.6
#8  ?? () from /lib/x86_64-linux-gnu/libc.so.6

두 백트레이스의 바닥이 다릅니다. 첫째는 main() 에서 올라온 메인 스레드이고, 둘째는 handle_one_connection() 에서 시작하는 새 스레드입니다. 접속을 받는 일과 로그인하는 일이 서로 다른 스레드에서 일어난다는 것이 그대로 보입니다. pfs_spawn_thread 는 performance_schema 가 스레드를 만들 때 끼워 넣는 감싸개입니다(이 빌드는 performance_schema 를 넣어 빌드했으므로, 실행 중에 꺼져 있어도 이 프레임이 보입니다). 줄 번호는 모두 기준 커밋의 소스와 맞는 것을 확인했습니다.

예제 2: "Too many connections" 는 두 곳에서 난다

max_connections + 1 의 한 자리를 확인하려고 --max-connections=10 으로 서버를 띄우고, 권한이 없는 사용자 app 으로 연결 10개를 열어 둔 채 다음을 시도했습니다(ex/u04-max-connections).

# 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
# ...
lab-start --max-connections=10 > /dev/null
mariadb -e "CREATE USER app@localhost; SELECT @@max_connections"

echo "--- 1. ten idle 'app' clients"
for i in $(seq 1 10); do (sleep 8 | mariadb -u app) & done
sleep 3

echo "--- 2. 11th client as 'app'"
mariadb -u app -e "SELECT 'app got in' AS result" 2>&1 || true

echo "--- 3. 11th client as root (admin)"
# 관리자 연결 하나를 SLEEP(4) 로 열어 둔 채 다음 단계를 본다 (-n: 문장마다 바로 출력)
mariadb -n -N -e "SELECT 'root got in'; SELECT SLEEP(4)" > /tmp/root.out 2>&1 &
sleep 1.5
cat /tmp/root.out

echo "--- 4. 12th client as root"
mariadb -e "SELECT 'second root got in' AS result" 2>&1 || true
# ...

[예제 ex/u04-max-connections/run.sh 에서 줄임]

@@max_connections
10
--- 1. ten idle 'app' clients
--- 2. 11th client as 'app'
ERROR 1040 (08004): Too many connections
--- 3. 11th client as root (admin)
root got in
--- 4. 12th client as root
ERROR 2002 (HY000): Received error packet before completion of TLS handshake. The authenticity of the following error cannot be verified: 1040 - Too many connections
--- 5. status after everyone left
+-----------------------------------+-------+
 Variable_name                     | Value |
+-----------------------------------+-------+
 Aborted_connects                  | 2     |
 Connection_errors_max_connections | 1     |
 Max_used_connections              | 11    |
+-----------------------------------+-------+

같은 1040 번 오류인데 모양이 다릅니다.

상태 변수도 이 차이를 그대로 셉니다. Connection_errors_max_connections 는 CONNECT::close_and_delete() 가 ER_CON_COUNT_ERROR 일 때만 올리므로(1585–1586행) 12번째 root 하나만 세었고, Aborted_connects 는 두 번 모두 세었습니다.

헷갈리기 쉬운 곳: 관리자 몫의 한 자리는 SUPER 가 아니라 CONNECTION ADMIN

예전 MySQL 문서에서 "SUPER 권한을 가진 사용자를 위해 한 자리를 남겨 둔다"는 설명을 본 분이 많을 것입니다. 기준 커밋에서 이 검사가 보는 권한은 PRIV_IGNORE_MAX_CONNECTIONS 이고, 이것은 CONNECTION_ADMIN_ACL 과 같습니다(sql/privilege.h 428행). 권한이 잘게 나뉜 뒤의 이름입니다. 그리고 이 자리는 "관리자가 몇 명이든 하나"입니다. 예제의 12번째 root 처럼 관리자도 두 번째부터는 막힙니다.

2. 스케줄러 — thread_handling 이 고르는 함수 포인터 묶음

add_connection 이 무엇을 가리키는지는 기동할 때 정해집니다. 먼저 구조체를 봅니다.

struct scheduler_functions
{
  uint max_threads;
  Atomic_counter<uint> *connection_count;
  ulong *max_connections;
  bool (*init)(void);
  void (*add_connection)(CONNECT *connect);
  void (*thd_wait_begin)(THD *thd, int wait_type);
  void (*thd_wait_end)(THD *thd);
  void (*post_kill_notification)(THD *thd);
  void (*end)(void);
  /** resume previous unfinished command (threadpool only)*/
  void (*thd_resume)(THD* thd);
};

sql/scheduler.h 28–41행

함수 포인터는 일곱 개이고, 스레드 풀에서만 쓰는 thd_resume 을 빼면 이것이 스케줄러의 약속입니다.

함수 포인터 언제 불리나
add_connection 메인 스레드가 새 연결을 넘길 때 (1절)
thd_wait_begin / thd_wait_end 연결이 잠금·디스크·SLEEP() 같은 것을 기다리기 시작할 때와 끝날 때 (7절)
post_kill_notification 다른 연결이 KILL 했을 때, 기다리는 연결을 깨우려고 (8절)
init / end 서버가 시작할 때와 끝날 때

비어 있는 포인터도 됩니다. 부르는 쪽은 MYSQL_CALLBACK(obj, func, params) 매크로로 "객체와 함수가 있으면 부른다"를 한 줄로 씁니다(sql/sql_callback.h 33–37행). 연결마다 스레드를 두는 모드는 thd_wait_begin 을 채우지 않으므로, 기다림 알림은 그 모드에서 아무 일도 하지 않습니다.

세 가지 값과 기본값

static const char *thread_handling_names[]=
{
  "one-thread-per-connection", "no-threads",
#ifdef HAVE_POOL_OF_THREADS
  "pool-of-threads",
#endif
  0
};

#if defined (_WIN32) && defined (HAVE_POOL_OF_THREADS)
/* Windows is using OS threadpool, so we're pretty sure it works well */
#define DEFAULT_THREAD_HANDLING 2
#else
#define DEFAULT_THREAD_HANDLING 0
#endif

sql/sys_vars.cc 3478–3492행

기본값이 운영체제마다 다릅니다. Linux 에서는 0번 one-thread-per-connection, Windows 에서는 2번 pool-of-threads 입니다(주석: Windows 는 운영체제의 스레드 풀을 쓰니 잘 동작한다고 확신한다). HAVE_POOL_OF_THREADS 는 sql/CMakeLists.txt 가 Linux·Solaris·Windows·kqueue 가 있는 시스템에서 켭니다(229–234행). 변수는 READ_ONLY 로 선언되어 있어(3497행) SET GLOBAL 로 바꿀 수 없고 my.cnf 나 기동 옵션으로만 정합니다.

값에 따라 구조체를 채우는 곳은 get_options() 입니다.

  if (thread_handling <= SCHEDULER_ONE_THREAD_PER_CONNECTION)
    one_thread_per_connection_scheduler(thread_scheduler, &max_connections,
                                        &connection_count);
  else if (thread_handling == SCHEDULER_NO_THREADS)
    one_thread_scheduler(thread_scheduler, &connection_count);
  else
    pool_of_threads_scheduler(thread_scheduler,  &max_connections,
                                        &connection_count); 

  one_thread_per_connection_scheduler(extra_thread_scheduler,
                                      &extra_max_connections,
                                      &extra_connection_count);

sql/mysqld.cc 9393–9404행

thread_handling 값이 어느 스케줄러 함수로 가는지 나타낸 그림. one-thread-per-connection은 Linux의 기본값으로 one_thread_per_connection_scheduler가 add_connection에 create_thread_to_handle_connection을 넣는다. no-threads는 one_thread_scheduler가 handle_connection_in_main_thread를 넣는다. pool-of-threads는 Windows의 기본값으로 pool_of_threads_scheduler가 tp_add_connection을 넣고 thd_wait_begin과 thd_wait_end도 채운다. extra_port로 들어온 연결은 모드와 상관없이 늘 one_thread_per_connection_scheduler를 쓴다. extra_port 의 스케줄러는 thread_handling 과 상관없이 늘 "연결마다 스레드"입니다.

마지막 세 줄이 중요합니다. extra_port 는 언제나 연결마다 스레드 하나입니다. 스레드 풀이 막혔을 때 관리자가 들어와 상황을 볼 수 있게 남겨 둔 비상구입니다. 스레드 풀이 워커를 더 만들지 못할 때 오류 로그에 남기는 메시지도 "extra_port 로 관리자 계정으로 들어와 문제를 살펴볼 수 있다"고 안내합니다(threadpool_generic.cc 1724–1732행). extra_max_connections 의 기본값은 1입니다(sys_vars.cc 7145행).

no-threads 를 고르면 add_connection 이 handle_connection_in_main_thread() 가 되고, 이 함수는 do_handle_one_connection(connect, false) 한 줄입니다(6671–6674행). 메인 스레드가 그 연결이 끝날 때까지 직접 처리하므로 그동안 다른 접속은 받지 못합니다.

헷갈리기 쉬운 곳: enum 에는 pool-of-threads 가 없다

scheduler.h 의 enum scheduler_types 에는 SCHEDULER_ONE_THREAD_PER_CONNECTION=0, SCHEDULER_NO_THREADS, SCHEDULER_TYPES_COUNT 셋뿐입니다(57–69행). 2번(pool-of-threads)에 해당하는 이름이 따로 없고, 위 코드처럼 "둘 다 아니면(else)" 스레드 풀로 갑니다. 그래서 다른 곳에서 "스레드 풀인가"를 물을 때는 thread_handling == SCHEDULER_TYPES_COUNT 라고 씁니다(예: sql/wsrep_thd.cc 509행). 처음 읽으면 "개수와 같은지 비교한다"는 코드가 버그처럼 보이지만 의도된 것입니다. enum 위의 주석도 "마지막 값은 동적인 스레드 처리를 표시하는 데도 쓰인다"고 적어 두었습니다(53–55행).

3. 연결마다 스레드 하나 — handle_one_connection() 과 스레드 캐시

기본 모드에서 add_connection 은 create_thread_to_handle_connection() 입니다(scheduler.cc 117행).

void create_thread_to_handle_connection(CONNECT *connect)
{
  DBUG_ENTER("create_thread_to_handle_connection");

  if (thread_cache.enqueue(connect))
    DBUG_VOID_RETURN;

  /* Create new thread to handle connection */
  inc_thread_created();
  DBUG_PRINT("info",(("creating thread %lu"), (ulong) connect->thread_id));
  connect->prior_thr_create_utime= microsecond_interval_timer();

  pthread_t tmp;
  if (auto error= mysql_thread_create(key_thread_one_connection,
                                      &tmp, &connection_attrib,
                                      handle_one_connection, (void*) connect))
  {
    // ... (ER_CANT_CREATE_THREAD 를 보내고 연결을 닫는다)
  }
  DBUG_PRINT("info",("Thread created"));
  DBUG_VOID_RETURN;
}

sql/mysqld.cc 6681–6711행

먼저 캐시에 재워 둔 스레드가 있으면 그 스레드에게 맡기고(enqueue()), 없을 때만 새 스레드를 만듭니다. 새로 만들 때만 inc_thread_created() 로 Threads_created 상태 변수를 올립니다. 그래서 Threads_created 가 계속 늘어난다면 캐시가 모자라다는 뜻입니다.

연결 스레드의 일생

새 스레드는 handle_one_connection() 에서 시작합니다. 스레드 이름을 one_connection 으로 붙이고(1421행, Linux 에서는 pthread_setname_np() 로 운영체제 스레드 이름이 됩니다) do_handle_one_connection(connect, true) 를 부릅니다. 핵심은 이 함수의 바깥 for 루프입니다.

  for (;;)
  {
    bool create_user= TRUE;

    mysql_socket_set_thread_owner(thd->net.vio->mysql_socket);
    if (thd_prepare_connection(thd))
    {
      create_user= FALSE;
      goto end_thread;
    }      

    while (thd_is_connection_alive(thd))
    {
      if (mysql_audit_release_required(thd))
        mysql_audit_release(thd);
      if (do_command(thd))
	break;
    }
    end_connection(thd);

end_thread:
    close_connection(thd);

    if (thd->userstat_running)
      update_global_user_stats(thd, create_user, time(NULL));

    unlink_thd(thd);
    if (IF_WSREP(thd->wsrep_applier, false) || !put_in_cache ||
        !(connect= thread_cache.park()))
      break;
    // ... (새 연결을 위한 performance_schema 계측 생략)
    if (!(connect->create_thd(thd)))
    {
      // ... (자원이 모자라면 연결을 닫고 스레드를 끝낸다)
    }
    delete connect;
    // ... (store_globals() 로 스레드 전역을 새 연결 것으로)
    server_threads.insert(thd);
  }
  delete thd;

sql/sql_connect.cc 1499–1555행

연결마다 스레드를 두는 모드에서 스레드의 상태도. handle_one_connection이 create_thd(NULL)로 새 THD를 만들고 로그인 상태가 된다. thd_prepare_connection이 성공하면 명령 상태로 가서 do_command로 패킷 하나씩 읽고 처리하기를 되풀이한다. 인증 실패나 연결 수 초과, 또는 COM_QUIT·끊김·KILL이면 정리 상태로 간다. 정리 뒤 unlink_thd를 하고 thread_cache.park를 불러, 캐시에 자리가 있으면 캐시 상태로, 꽉 찼으면 THD를 지우고 끝난다. 캐시에서 새 연결이 오면 create_thd(thd)로 THD를 다시 써서 로그인 상태로 가고, 5분 동안 연결이 없거나 FLUSH THREADS나 서버 종료면 끝난다. 스레드 하나가 여러 연결을 차례로 맡습니다. 캐시에서 깨어난 스레드는 THD 객체까지 다시 씁니다.

읽을 것이 셋입니다.

  1. 안쪽 while 이 연결의 일생입니다. do_command() 가 패킷 하나를 읽어 처리하고 돌아오기를 연결이 살아 있는 동안 되풀이합니다. do_command() 안쪽(패킷 읽기와 dispatch_command())은 5편에서 봅니다. 이 모드에서는 연결이 놀고 있는 동안에도 이 스레드가 do_command() 안에서 다음 패킷을 기다립니다(5절에서 gdb 로 봅니다).
  2. 바깥 for 가 스레드의 일생입니다. 연결이 끝나면 thread_cache.park() 로 캐시에 들어가 기다리고, 새 연결을 받으면 처음으로 돌아갑니다. park() 가 NULL 을 돌려주면(캐시가 꽉 찼거나 시간이 다 됐으면) 루프를 빠져나와 delete thd 후 스레드가 끝납니다.
  3. THD 를 다시 씁니다. 처음에는 connect->create_thd(NULL)(1470행)로 새 THD 를 만들지만, 캐시에서 깨어난 뒤에는 connect->create_thd(thd) 로 쓰던 THD 를 넘깁니다. create_thd() 는 이때 thd->reset_for_reuse() 로 THD 를 비우고 연결 번호만 새것으로 바꿉니다(1626–1636행). 큰 객체를 다시 할당하지 않으려는 것입니다.

Thread_cache::park() — 캐시는 큐 하나와 조건 변수

    mysql_mutex_lock(&LOCK_thread_cache);
    if ((connect= list.get()))
      cached_thread_count++;
    else if (cached_thread_count < thread_cache_size && !kill_cached_threads)
    {
      /* Don't kill the thread, just put it in cache for reuse */
      DBUG_PRINT("info", ("Adding thread to cache"));
      cached_thread_count++;
      for (;;)
      {
        int error= mysql_cond_timedwait(&COND_thread_cache, &LOCK_thread_cache,
                                         &abstime);
        flushed= kill_cached_threads;
        if ((connect= list.get()))
          break;
        else if (flushed || error == ETIMEDOUT || error == ETIME)
        {
          /*
            If timeout, end thread.
            If a new thread is requested, we will handle the call, even if we
            got a timeout (as we are already awake and free)
          */
          cached_thread_count--;
          break;
        }
      }
    }
    mysql_mutex_unlock(&LOCK_thread_cache);

sql/thread_cache.h 166–193행

캐시는 CONNECT 의 대기 줄(list) 하나와 조건 변수 COND_thread_cache 하나입니다. 재워 둔 스레드는 조건 변수에서 기다리다가, 메인 스레드의 enqueue() 가 줄에 CONNECT 를 넣고 mysql_cond_signal() 을 보내면(119–132행) 깨어나 꺼내 갑니다. cached_thread_count 가 Threads_cached 상태 변수입니다(show_cached_thread_count(), mysqld.cc 7973–7980행). 기다리는 시간 abstime 은 THREAD_CACHE_TIMEOUT, 곧 5*60 초입니다(sql/sql_const.h 262행). 5분 동안 새 연결이 없으면 스레드는 스스로 끝납니다. thread_cache_size 의 설명 문구도 "5분 동안 놀면 풀린다"입니다(sys_vars.cc 4493행). FLUSH THREADS 는 thread_cache.flush() 로 재워 둔 스레드를 모두 깨워 끝냅니다(sql_reload.cc 398–399행).

예제 3: 연결 수와 스레드 수가 같이 움직인다

기본 모드로 띄운 서버에 놀고 있는 클라이언트 5개를 붙였다가 떼고, 다시 3개를 붙이며 상태 변수와 ps -L 로 센 one_connection 스레드 수를 찍었습니다(ex/u04-one-thread). 놀고 있는 클라이언트는 (sleep 6 | mariadb) & 처럼 표준 입력이 비어 있는 mariadb 로 만들었습니다. 접속만 해 두고 아무것도 보내지 않습니다.

--- 0. settings
@@thread_handling	@@thread_cache_size	@@max_connections
one-thread-per-connection	151	151
--- 1. before
connected=1 created=1 cached=0 
one_connection threads (ps -L): 1
--- 2. five idle clients connected
connected=6 created=6 cached=0 
one_connection threads (ps -L): 6
+---------------------------+------+-------+
 NAME                      | ID   | CMD   |
+---------------------------+------+-------+
 thread/sql/one_connection |    6 | Sleep |
 thread/sql/one_connection |    7 | Sleep |
 thread/sql/one_connection |    8 | Sleep |
 thread/sql/one_connection |    9 | Sleep |
 thread/sql/one_connection |   10 | Sleep |
 thread/sql/one_connection |   12 | Query |
+---------------------------+------+-------+
--- 3. the five clients disconnected
connected=1 created=6 cached=5 
one_connection threads (ps -L): 6
--- 4. three new idle clients
connected=4 created=6 cached=2 
one_connection threads (ps -L): 6

상태 변수를 묻는 mariadb -e 도 연결 하나이므로 connected 에는 늘 자기 자신이 1 들어 있습니다. 표는 performance_schema.threads 에서 thread/sql/one_connection 인 스레드를 고른 것입니다(이 표를 찍으려고 이 예제의 서버는 --performance-schema=ON 으로 띄웠습니다). 연결 번호는 실행마다 다릅니다.

헷갈리기 쉬운 곳: thread_cache_size 의 기본값 256 이 151 로 보인다

Sys_thread_cache_size 의 DEFAULT 는 256 입니다(sys_vars.cc 4495행). 그런데 예제 0단계에서는 151 이 나왔습니다. get_options() 의 끝에서 "캐시가 max_connections 보다 클 필요는 없다"며 줄이기 때문입니다.

  /* Ensure that some variables are not set higher than needed */
  if (thread_cache_size > max_connections)
    SYSVAR_AUTOSIZE(thread_cache_size, max_connections);

sql/mysqld.cc 9439–9441행

max_connections 의 기본값이 151(MAX_CONNECTIONS_DEFAULT, sql_const.h 168행)이므로 기본 설정에서는 캐시도 151 이 됩니다. 문서의 기본값과 SELECT @@thread_cache_size 가 다르다면 이 때문입니다.

4. 로그인 — THD 를 만들고 인증한다

연결 스레드가 맨 처음 하는 일은 create_thd() 와 thd_prepare_connection() 입니다.

create_thd() — THD 와 Vio, NET

  if (thd)
  {
    /* reuse old thd */
    thd->reset_for_reuse();
    /*
      reset tread_id's, but not thread_dbug_id's as the later isn't allowed
      to change as there is already structures in thd marked with the old
      value.
    */
    thd->thread_id= thd->variables.pseudo_thread_id= thread_id;
  }
  else if (!(thd= new THD(thread_id)))
    DBUG_RETURN(0);

sql/sql_connect.cc 1626–1638행

새 THD 는 new THD(thread_id) 로 만듭니다. 생성자(sql_class.cc 721행~)는 끝에서 init() 을 부르고(960행), THD::init() 은 plugin_thdvar_init() 으로 전역 시스템 변수를 세션 변수로 복사합니다(1388–1399행). SET GLOBAL 로 바꾼 값이 새 연결부터 적용되는 이유가 이것입니다. 이미 열려 있는 연결의 THD 는 그때 복사한 값을 계속 씁니다.

그다음 create_thd() 는 소켓을 Vio(MariaDB 의 소켓 추상화)로 감싸고 my_net_init() 으로 THD 의 NET(패킷 읽기·쓰기 버퍼)에 붙입니다(1645–1655행). 유닉스 소켓으로 들어온 연결이면 이 자리에서 호스트를 localhost 로 정해 둡니다(1668–1670행). Vio 와 NET 의 안쪽은 5편의 주제입니다. THD 가 준비되면 do_handle_one_connection() 은 server_threads.insert(thd) 로 THD 를 서버의 연결 목록에 넣습니다(1490행). SHOW PROCESSLIST 에 연결이 보이기 시작하는 것이 이 순간입니다(주석: "Make THD visible in show processlist"). 아직 인증 전이므로 이때 PROCESSLIST 의 User 는 unauthenticated user 로 보입니다.

thd_prepare_connection() → acl_authenticate()

bool thd_prepare_connection(THD *thd)
{
  bool rc;
  lex_start(thd);
  rc= login_connection(thd);
  if (rc)
    return rc;

  MYSQL_CONNECTION_START(thd->thread_id, &thd->security_ctx->priv_user[0],
                         (char *) thd->security_ctx->host_or_ip);

  prepare_new_connection_state(thd);
#ifdef WITH_WSREP
  thd->wsrep_client_thread= true;
  wsrep_open(thd);
#endif /* WITH_WSREP */
  return FALSE;
}

sql/sql_connect.cc 1436–1453행

예제 1의 두 번째 백트레이스가 이 길이었습니다. 단계별로 하는 일은 이렇습니다.

함수 하는 일
login_connection() 핸드셰이크 동안은 읽기·쓰기 제한 시간을 connect_timeout 으로 둔다(1249–1250행). 성공하면 net_read_timeout·net_write_timeout 으로 되돌린다
check_connection() TCP 면 상대 주소를 얻어 호스트 캐시를 확인한다(thd_set_peer_addr()). TCP keep-alive 를 켜고 acl_authenticate(thd, 0) 을 부른다(1184행)
acl_authenticate() 서버 인사 패킷을 보내고, 사용자 이름을 받아, 그 계정의 인증 플러그인으로 비밀번호를 확인한다. 계정 잠금·비밀번호 만료·max_user_connections·max_connections 를 검사하고, 처음 쓸 데이터베이스로 바꾼 뒤 OK 패킷을 보낸다
prepare_new_connection_state() init_for_queries() 로 질의를 받을 준비를 하고, init_connect 가 설정돼 있으면 실행한다(CONNECTION ADMIN 권한이 있으면 건너뛴다, 1354–1357행)

연결 스레드의 로그인 순서. 먼저 create_thd() 가 new THD(thread_id) 로 THD 를 만들고(캐시에서 깨어났으면 reset_for_reuse()), THD::init() 의 plugin_thdvar_init() 이 전역 변수를 세션 변수로 복사하고, 소켓을 Vio 로 감싸 my_net_init() 으로 NET 에 붙인다. 그다음 server_threads.insert(thd) 로 PROCESSLIST 에 보이기 시작하며 User 는 unauthenticated user 이다. 이어서 thd_prepare_connection() 이 login_connection()(핸드셰이크 동안 제한 시간은 connect_timeout), check_connection()(상대 주소, 호스트 캐시, keep-alive), acl_authenticate()(기본 플러그인 mysql_native_password 로 인사 패킷과 사용자 이름, 계정이 다른 플러그인이면 do_auth_once() 로 다시, 계정 잠금·만료·max_connections 두 번째 검사 뒤 OK 패킷), prepare_new_connection_state()(init_connect 실행, CONNECTION ADMIN 이면 건너뜀)를 차례로 하고 do_command() 반복으로 간다. 인증에 실패하면 goto end_thread 로 close_connection() 뒤 park() 해 스레드가 캐시로 간다 위의 표와 앞뒤 인용을 한 줄로 이은 그림입니다. 노란 칸부터 연결이 PROCESSLIST 에 보입니다.

인증은 플러그인이 합니다. acl_authenticate() 는 먼저 기본 플러그인(default_auth_plugin_name, 기준 커밋에서는 mysql_native_password, 93행)으로 한 번 시도해 서버 인사 패킷을 보내고 사용자 이름을 받은 뒤, 그 계정이 다른 플러그인을 쓰면 그 플러그인으로 다시 시도합니다(17085행, 17101–17108행). 플러그인 하나를 부르는 곳은 do_auth_once() 입니다.

  if (plugin)
  {
    st_mysql_auth *info= (st_mysql_auth *) plugin_decl(plugin)->info;
    switch (info->interface_version >> 8) {
    case 0x02:
      res= info->authenticate_user(mpvio, &mpvio->auth_info);
      break;

sql/sql_acl.cc 16951–16957행

authenticate_user 도 함수 포인터입니다. mysql_native_password 플러그인은 같은 파일 끝에서 maria_declare_plugin(mysql_password) 로 선언되어 이 자리에 native_password_authenticate() 를 넣습니다(17579–17612행). 엔진이 handlerton 을 채우듯 인증 방법도 플러그인이 구조체를 채우는 방식입니다(3편). 인증 플러그인과 권한 표의 자세한 내용은 이 연재에서 따로 다루지 않습니다. 권한 확인은 7편에서 봅니다.

1절에서 미뤄 둔 max_connections 의 두 번째 검사도 acl_authenticate() 안에 있습니다.

  if (command == COM_CONNECT &&
      !(thd->main_security_ctx.master_access & PRIV_IGNORE_MAX_CONNECTIONS))
  {
    if (*thd->scheduler->connection_count > *thd->scheduler->max_connections)
    {                                         // too many connections
      my_error(ER_CON_COUNT_ERROR, MYF(0));
      DBUG_RETURN(1);
    }
  }

sql/sql_acl.cc 17288–17296행

인증이 끝나 누구인지 안 다음에야 권한을 보고 "한 자리 더"를 허락할 수 있기 때문에, 검사가 두 번으로 나뉜 것입니다. create_new_thread() 는 권한을 모르는 메인 스레드에서 "적어도 max_connections + 1 은 넘지 말자"는 대강의 검사(주석: "We roughly check here")만 하고, 정확한 검사는 연결 스레드가 인증 뒤에 합니다.

헷갈리기 쉬운 곳: 로그인에 실패해도 스레드는 캐시로 간다

3절의 루프를 다시 보면 thd_prepare_connection() 이 실패했을 때 goto end_thread 로 뛰어 close_connection(), unlink_thd() 를 하고 똑같이 park() 에 들어갑니다. 비밀번호를 틀린 연결도 스레드를 하나 만들거나 캐시에서 깨우고, 끝나면 그 스레드를 캐시에 돌려줍니다. 잘못된 접속이 쏟아지면 그만큼 스레드가 바쁘게 깨어난다는 뜻입니다.

5. 놀고 있는 연결은 어디서 기다리나

연결은 대부분의 시간을 놀면서 보냅니다. 애플리케이션의 커넥션 풀은 연결을 미리 열어 두고 필요할 때만 질의를 보내기 때문입니다(Spring 18편에서 본 DataSource 뒤의 커넥션 풀). 그동안 서버의 스레드는 무엇을 하고 있을까요?

예제 4: gdb 로 본 놀고 있는 연결

놀고 있는 클라이언트 하나를 붙여 두고 gdb -batch -ex 'thread apply all bt' 로 모든 스레드의 백트레이스를 찍은 뒤, 필요한 스레드만 골랐습니다(ex/u04-gdb 의 1부).

=== 1. one-thread-per-connection: an idle client
      1 checkpoint_bg
      6 ib_tpool_worker
      1 io_uring_wait
      1 manager
      3 mariadbd
      1 one_connection
      1 page_cleaner
      1 signal_handler
      1 statement_timer
Thread ... "one_connection"
#0  poll () from /lib/x86_64-linux-gnu/libc.so.6
#1  vio_io_wait at /usr/src/mariadb/vio/viosocket.c:960
#2  vio_socket_io_wait at /usr/src/mariadb/vio/viosocket.c:116
#3  handle_ssl_io_error at /usr/src/mariadb/vio/viossl.c:163
#4  vio_ssl_read at /usr/src/mariadb/vio/viossl.c:179
#5  my_real_read at /usr/src/mariadb/sql/net_serv.cc:847
#6  my_net_read_packet_reallen at /usr/src/mariadb/sql/net_serv.cc:1084
#7  my_net_read_packet at /usr/src/mariadb/sql/net_serv.cc:1067
#8  do_command at /usr/src/mariadb/sql/sql_parse.cc:1293
#9  do_handle_one_connection at /usr/src/mariadb/sql/sql_connect.cc:1514
#10 handle_one_connection at /usr/src/mariadb/sql/sql_connect.cc:1426
#11 pfs_spawn_thread at /usr/src/mariadb/storage/perfschema/pfs.cc:2198
#12 ?? () from /lib/x86_64-linux-gnu/libc.so.6
#13 ?? () from /lib/x86_64-linux-gnu/libc.so.6

Thread ... "mariadbd"
#0  poll () from /lib/x86_64-linux-gnu/libc.so.6
#1  handle_connections_sockets at /usr/src/mariadb/sql/mysqld.cc:6890
#2  run_main_loop at /usr/src/mariadb/sql/mysqld.cc:6171
#3  mysqld_main at /usr/src/mariadb/sql/mysqld.cc:6590
#4  main at /usr/src/mariadb/sql/main.cc:34

맨 위의 목록은 스레드 이름별 개수입니다(ib_tpool_worker 는 InnoDB 의 내부 스레드 풀 tpool 의 스레드입니다. 두 번 돌려 두 번 다 6개였지만, tpool 은 일감에 따라 스레드를 늘리고 줄이므로 늘 6개라는 보장은 없습니다). 놀고 있는 연결 하나에 one_connection 스레드 하나가 있고, 그 스레드는 do_command() 의 1293행 my_net_read_packet(net, 1) 에서 다음 패킷을 기다리며 poll() 에 멈춰 있습니다. 소켓 하나를 바라보는 poll() 입니다. 메인 스레드(mariadbd 라는 이름의 셋 가운데 하나)는 1절의 poll() 에서 새 접속을 기다리고 있습니다.

예제 4의 백트레이스 두 개를 나란히 그린 그림. one_connection 스레드(놀고 있는 연결 하나)는 do_handle_one_connection(sql_connect.cc:1514) → do_command()(sql_parse.cc:1293, 읽기 제한 시간은 wait_timeout) → my_net_read_packet → my_real_read(net_serv.cc) → vio_ssl_read → vio_io_wait(TLS 연결이라 viossl) → poll() 로 내려가, 이 연결의 소켓 하나에서 다음 패킷을 기다린다. 메인 스레드(mariadbd)는 mysqld_main → run_main_loop → handle_connections_sockets()(mysqld.cc:6890) → poll() 에서 듣기 소켓들로 새 접속을 기다린다 놀고 있는 연결 하나마다 이런 스레드가 하나씩 poll() 에 멈춰 있습니다. 함수와 줄 번호는 예제 4의 실제 출력에서 가져왔습니다.

#3·#4 에 viossl 이 보이는 것은 이 연결이 TLS 로 암호화되어 있기 때문입니다. 유닉스 소켓으로 붙었는데도 기준 커밋의 클라이언트와 서버는 TLS 를 썼습니다(예제 2의 2002 번 오류 문구와 같은 사정입니다).

얼마나 오래 기다리는지는 do_command() 가 읽기 전에 정합니다.

  if (!thd->skip_wait_timeout)
    my_net_set_read_timeout(net, thd->get_net_wait_timeout());

sql/sql_parse.cc 1266–1267행

이 모드에서 wait_timeout 은 곧 "소켓 읽기의 제한 시간"입니다. 시간이 지나면 읽기가 실패하고 연결이 닫힙니다. skip_wait_timeout 이 참이면 건너뛰는데, 이것을 켜는 쪽이 스레드 풀입니다(6절).

이 모델의 장단점이 여기서 드러납니다. 코드가 단순하고(읽고, 처리하고, 다시 읽는다), 한 연결의 질의가 오래 걸려도 다른 연결과 상관이 없습니다. 대신 놀고 있는 연결 수만큼 스레드가 있습니다. 연결이 수천 개면 스레드도 수천 개이고, 각 스레드의 스택 메모리와 운영체제의 스케줄링 부담이 따라옵니다. 그 연결들이 한꺼번에 질의를 보내면 CPU 수보다 훨씬 많은 스레드가 동시에 돌며 서로를 밀어냅니다. httpd 의 worker MPM 이 keep-alive 연결 때문에 겪는 문제와 같습니다(httpd 8편의 4절).

6. 스레드 풀 — 그룹, 리스너, 워커

--thread-handling=pool-of-threads 로 띄우면 pool_of_threads_scheduler() 가 구조체를 통째로 바꿔 끼웁니다.

static scheduler_functions tp_scheduler_functions=
{
  0,                                  // max_threads
  NULL,
  NULL,
  tp_init,                            // init
  tp_add_connection,                  // add_connection
  tp_wait_begin,                      // thd_wait_begin
  tp_wait_end,                        // thd_wait_end
  tp_post_kill_notification,          // post kill notification
  tp_end,                              // end
  tp_resume
};

sql/threadpool_common.cc 616–628행

연결마다 스레드를 두는 모드와 달리 함수 포인터가 모두 채워져 있습니다. 특히 thd_wait_begin·thd_wait_end 가 7절의 핵심입니다. 파일은 둘로 나뉩니다. threadpool_common.cc 는 플랫폼과 상관없는 부분(로그인, 질의 하나 처리)이고, threadpool_generic.cc 는 epoll·kqueue 같은 운영체제 기능 위에 그룹과 워커를 만드는 부분입니다. Windows 에는 운영체제의 스레드 풀을 쓰는 threadpool_win.cc 가 따로 있고, Linux 빌드는 TP_pool_generic 만 씁니다(tp_init(), 471–494행).

연결은 그룹 하나에 묶인다

스레드 풀의 구조. 메인 스레드의 create_new_thread가 tp_add_connection을 부르고, thread_id를 thread_pool_size로 나눈 나머지로 그룹을 고른다. 그룹 하나(thread_group_t) 안에는 높은 우선순위와 낮은 우선순위의 작업 큐 둘, 워커 하나가 맡는 리스너, 실행 중인 워커, 잠든 워커의 대기 목록이 있다. 리스너는 epoll_wait으로 그룹의 모든 연결 소켓을 지켜보다가 읽을 것이 온 연결을 큐에 넣는다. 워커는 queue_get으로 큐에서 꺼내 tp_callback과 do_command를 부르고, 끝나면 start_io로 소켓을 다시 epoll에 건다. 대기 목록의 워커는 wake_thread로 깨어난다. 풀에 하나인 timer_thread는 thread_pool_stall_limit마다 check_stall을 부르고 wait_timeout을 검사하며 멈춘 그룹을 깨운다. 노란 상자는 기다리는 곳(큐와 대기 목록), 파란 상자는 일하는 코드입니다. 그룹마다 epoll 이 하나씩 있습니다.

새 연결은 tp_add_connection() 에서 TP_connection_generic 객체가 되고(1320–1323행), 생성자에서 그룹이 정해집니다.

static size_t get_group_id(my_thread_id tid)
{
  return size_t(tid % group_count);
}

sql/threadpool_generic.cc 1396–1399행

연결 번호를 그룹 수로 나눈 나머지입니다(1422–1424행). group_count 는 thread_pool_size 와 같게 맞춰집니다(set_pool_size(), 1683행). 연결은 끝날 때까지 이 그룹에 묶입니다(thread_pool_size 를 실행 중에 바꾼 드문 경우만 change_group() 으로 옮깁니다). 그룹 하나의 구조체 thread_group_t(sql/threadpool_generic.h 131–151행)에서 이 글에 나오는 필드는 이렇습니다.

필드 뜻
queues[NQUEUES] 할 일이 있는 연결의 큐 둘(NQUEUES = 2, 높은·낮은 우선순위)
waiting_threads 할 일이 없어 잠든 워커들
listener 지금 epoll 을 지켜보는 워커(없으면 NULL)
pollfd 그룹의 epoll 파일 디스크립터
thread_count / active_thread_count 그룹의 워커 수 / 그 가운데 "실제로 CPU 를 쓰는" 워커 수
io_event_count / queue_event_count 정체 감지용 카운터 (7절)

워커의 루프: get_event()

워커 스레드(worker_main())는 get_event() 로 할 일 하나를 얻어 tp_callback() 을 부르기를 되풀이합니다(1558–1568행). get_event() 가 할 일을 찾는 순서가 스레드 풀의 성격을 정합니다.

    /* Check if queue is not empty */
    if (!oversubscribed)
    {
      connection = queue_get(thread_group, operation_origin::WORKER);
      if(connection)
      {
        break;
      }
    }

    /* If there is  currently no listener in the group, become one. */
    if(!thread_group->listener)
    {
      thread_group->listener= current_thread;
      thread_group->active_thread_count--;
      mysql_mutex_unlock(&thread_group->mutex);

      connection = listener(current_thread, thread_group);

      mysql_mutex_lock(&thread_group->mutex);
      thread_group->active_thread_count++;
      /* There is no listener anymore, it just returned. */
      thread_group->listener= NULL;
      break;
    }

sql/threadpool_generic.cc 1186–1210행

워커가 get_event에서 할 일을 찾는 순서. 너무 많이 돌고 있지 않으면 큐에서 하나 꺼내고, 있으면 그 연결로 tp_callback을 부른다. 큐가 비었거나 과부하면 리스너가 없을 때 스스로 리스너가 되어 epoll_wait(-1)로 기다리다 이벤트를 받아 돌아온다. 리스너가 이미 있으면, 과부하가 아닐 때 epoll_wait(0)으로 기다리지 않고 한 번 더 보고, 이벤트가 있으면 처리한다. 없으면 대기 목록 맨 앞에 들어가 잠든다. wake_thread로 깨어나면 처음으로 돌아가고, thread_pool_idle_timeout이 지나면 스레드가 끝난다. 리스너는 따로 있는 스레드가 아니라 워커들이 돌아가며 맡는 역할입니다.

  1. 큐부터 봅니다. 할 일이 쌓여 있으면 꺼내 처리합니다. 다만 그룹에서 이미 너무 많은 워커가 돌고 있으면(too_many_threads(): 활동 중인 워커가 1 + thread_pool_oversubscribe 를 넘고 정체 상태가 아니면, 1141–1145행) 큐를 건너뜁니다. 기본값 thread_pool_oversubscribe=3(sys_vars.cc 4595행) 이면 그룹마다 동시에 CPU 를 쓰는 워커를 대략 4개로 묶어 두는 셈입니다.
  2. 리스너가 없으면 스스로 리스너가 됩니다. listener() 안에서 epoll_wait(-1) 로 그룹의 모든 연결을 지켜봅니다. 리스너가 되는 동안은 active_thread_count 에서 빠집니다. 기다리는 스레드는 "활동 중"으로 세지 않는다는 원칙입니다.
  3. 리스너가 이미 있으면 기다리지 않는 epoll_wait(0) 으로 한 번 더 보고(1218–1229행), 그래도 없으면 대기 목록의 맨 앞에 들어가 잠듭니다(1240행). 맨 앞에 넣는 이유는 주석에 있습니다. 가장 최근에 일한 스레드부터 깨워(LIFO) CPU 캐시를 살리고, 오래 논 스레드는 계속 놀다가 thread_pool_idle_timeout(기본 60초, 4587행)이 지나면 끝나게 하려는 것입니다.

리스너가 이벤트를 받았을 때 직접 처리할지 큐에 넣을지는 listener() 가 정합니다.

    bool listener_picks_event=is_queue_empty(thread_group) && !threadpool_dedicated_listener;
    queue_put(thread_group, ev, cnt);
    if (listener_picks_event)
    {
      /* Handle the first event. */
      retval= queue_get(thread_group, operation_origin::LISTENER);
      mysql_mutex_unlock(&thread_group->mutex);
      break;
    }

sql/threadpool_generic.cc 798–806행

큐가 비어 있었으면 리스너가 첫 이벤트를 직접 맡아 리스너 자리를 떠납니다. 방금 깨어난 스레드가 다른 스레드를 깨우는 비용을 아끼려는 선택이라고 바로 위의 긴 주석(755–796행)이 설명합니다. 대가는 "그동안 그룹에 리스너가 없다"는 것이고, 그 빈틈은 7절의 timer_thread 가 메웁니다. thread_pool_dedicated_listener=1 로 두면 리스너는 질의를 맡지 않고 지켜보기만 합니다.

질의 하나를 처리하고 소켓을 다시 건다: tp_callback()

워커가 연결 하나를 얻으면 tp_callback() 을 부릅니다. 아직 THD 가 없는 연결이면 threadpool_add_connection() 으로 로그인부터 합니다(237–248행). 이 함수는 create_thd() 와 thd_prepare_connection() 을 부르므로 4절의 로그인이 워커 위에서 그대로 일어납니다. 로그인한 연결이면 threadpool_process_request() 가 do_command(thd, false) 로 명령 하나를 처리합니다(438행). 처리가 끝나면 이렇게 마무리합니다.

  /* Set priority */
  c->priority= get_priority(c);

  /* Read next command from client. */
  c->set_io_timeout(thd->get_net_wait_timeout());
  c->state= TP_STATE_IDLE;
  if (c->start_io())
    goto error;
  return;

sql/threadpool_common.cc 274–282행

epoll 에 거는 방식이 이 구조를 안전하게 합니다.

int io_poll_associate_fd(TP_file_handle pollfd, TP_file_handle fd, void *data, void*)
{
  struct epoll_event ev;
  ev.data.u64= 0; /* Keep valgrind happy */
  ev.data.ptr= data;
  ev.events=  EPOLLIN|EPOLLET|EPOLLERR|EPOLLRDHUP|EPOLLONESHOT;
  return epoll_ctl(pollfd, EPOLL_CTL_ADD,  fd, &ev);
}

sql/threadpool_generic.cc 184–191행

EPOLLONESHOT 은 "한 번 알리면 그 소켓을 지켜보기 목록에서 잠시 뺀다"는 뜻입니다. 질의를 처리하는 동안 같은 연결에 데이터가 더 와도 다른 워커가 그 연결을 집어 가지 않습니다. 처리가 끝나면 start_io() 가 EPOLL_CTL_MOD 로 다시 겁니다(io_poll_start_read(), 195–202행). 파일 머리의 주석(127–171행)도 지원하는 모든 플랫폼에서 이 "한 번만" 방식을 쓴다고 적어 두었습니다.

예제 5: 연결 20개, 워커 몇 개?

--thread-handling=pool-of-threads --thread-pool-size=2 로 띄우고 놀고 있는 클라이언트 20개를 붙였습니다(ex/u04-thread-pool). 놀고 있는 워커가 빨리 사라지는 것을 보려고 --thread-pool-idle-timeout=3 으로 줄였습니다(기본 60초).

--- 0. settings
+-------------------+--------------------+----------------------------+---------------------------+-----------------------------+
 @@thread_handling | @@thread_pool_size | @@thread_pool_idle_timeout | @@thread_pool_stall_limit | @@thread_pool_oversubscribe |
+-------------------+--------------------+----------------------------+---------------------------+-----------------------------+
 pool-of-threads   |                  2 |                          3 |                       500 |                           3 |
+-------------------+--------------------+----------------------------+---------------------------+-----------------------------+
--- 1. twenty idle clients, right after they logged in
Threads_connected=21
      1 timer_thread
     17 worker_thread
--- 2. the same twenty clients, 6 seconds later
Threads_connected=21
      1 timer_thread
      3 worker_thread
+----------+-------------+---------+----------------+-----------------+--------------+--------------+
 GROUP_ID | CONNECTIONS | THREADS | ACTIVE_THREADS | STANDBY_THREADS | QUEUE_LENGTH | HAS_LISTENER |
+----------+-------------+---------+----------------+-----------------+--------------+--------------+
        0 |          10 |       2 |              0 |               1 |            0 |            1 |
        1 |          11 |       2 |              1 |               1 |            0 |            0 |
+----------+-------------+---------+----------------+-----------------+--------------+--------------+

스레드 수는 ps -L 로 센 운영체제 스레드 이름입니다(one_connection 은 하나도 없습니다). 표는 information_schema.THREAD_POOL_GROUPS 로, sql/thread_pool_info.cc 가 thread_group_t 의 필드를 그대로 옮겨 보여 줍니다(STANDBY_THREADS 는 waiting_threads 의 길이, 44–76행). 이 정보 스키마 플러그인은 스레드 풀과 함께 서버에 정적으로 들어 있습니다(sql/CMakeLists.txt 240행).

같은 예제의 2부에서 놀고 있는 연결의 백트레이스를 다시 찍었습니다(ex/u04-gdb, --thread-pool-size=1).

=== 2. pool-of-threads: login on a worker, then an idle client
#0  thd_prepare_connection at /usr/src/mariadb/sql/sql_connect.cc:1439
#1  threadpool_add_connection at /usr/src/mariadb/sql/threadpool_common.cc:332
#2  tp_callback at /usr/src/mariadb/sql/threadpool_common.cc:241
#3  worker_main at /usr/src/mariadb/sql/threadpool_generic.cc:1567
#4  pfs_spawn_thread at /usr/src/mariadb/storage/perfschema/pfs.cc:2198
#5  ?? () from /lib/x86_64-linux-gnu/libc.so.6
#6  ?? () from /lib/x86_64-linux-gnu/libc.so.6

Thread ... "worker_thread"
#0  epoll_wait () from /lib/x86_64-linux-gnu/libc.so.6
#1  io_poll_wait at /usr/src/mariadb/sql/threadpool_generic.cc:222
#2  listener at /usr/src/mariadb/sql/threadpool_generic.cc:737
#3  get_event at /usr/src/mariadb/sql/threadpool_generic.cc:1203
#4  worker_main at /usr/src/mariadb/sql/threadpool_generic.cc:1563
#5  pfs_spawn_thread at /usr/src/mariadb/storage/perfschema/pfs.cc:2198
#6  ?? () from /lib/x86_64-linux-gnu/libc.so.6
#7  ?? () from /lib/x86_64-linux-gnu/libc.so.6

Thread ... "worker_thread"
#0  ?? () from /lib/x86_64-linux-gnu/libc.so.6
#1  pthread_cond_timedwait () from /lib/x86_64-linux-gnu/libc.so.6
#2  safe_cond_timedwait at /usr/src/mariadb/mysys/thr_mutex.c:544
#3  inline_mysql_cond_timedwait at /usr/src/mariadb/include/mysql/psi/mysql_thread.h:1088
#4  get_event at /usr/src/mariadb/sql/threadpool_generic.cc:1245
#5  worker_main at /usr/src/mariadb/sql/threadpool_generic.cc:1563
#6  pfs_spawn_thread at /usr/src/mariadb/storage/perfschema/pfs.cc:2198
#7  ?? () from /lib/x86_64-linux-gnu/libc.so.6
#8  ?? () from /lib/x86_64-linux-gnu/libc.so.6

스레드 이름 목록과 timer_thread 의 백트레이스(timer_thread() 575행의 cond_timedwait)는 빼고 실었습니다.

7. 기다림 알리기와 정체 감지 — thd_wait_begin() 과 timer_thread

스레드 풀은 그룹마다 "실제로 CPU 를 쓰는 워커"를 적게 유지하려 합니다. 그러면 질문이 생깁니다. 돌고 있는 워커가 행 잠금을 기다리거나 디스크를 읽느라 멈춰 버리면, 그룹의 다른 연결은 누가 처리할까요? 답은 두 겹입니다.

첫째 겹: 기다리는 쪽이 알린다

서버와 엔진은 오래 기다릴 만한 곳 앞뒤에 thd_wait_begin()·thd_wait_end() 를 부릅니다. 이 함수는 MYSQL_CALLBACK(thd->scheduler, thd_wait_begin, (thd, wait_type)) 한 줄로 스케줄러에 전달합니다(sql_class.cc 6221–6230행). 예를 들어 SLEEP() 은 이렇습니다.

  thd_wait_begin(thd, THD_WAIT_SLEEP);
  while (!thd->killed)
  {
    error= timed_cond->wait(cond, &LOCK_item_func_sleep);
    if (error == ETIMEDOUT || error == ETIME)
      break;
    error= 0;
  }
  thd_wait_end(thd);

sql/item_func.cc 7527–7535행

InnoDB 도 행 잠금 대기(lock/lock0lock.cc 2324행), 페이지 읽기(buf/buf0rea.cc 211행, 325행), redo 로그 쓰기를 다른 스레드가 끝내 주기를 기다리는 곳(log_write_up_to() 가 쓰는 group_commit_lock::acquire(), log/log0sync.cc 273행) 앞에서 같은 함수를 부릅니다. 네트워크 대기는 scheduler_init() 이 vio_set_wait_callback() 으로 걸어 둡니다(scheduler.cc 78–79행). 기다림의 종류는 THD_WAIT_SLEEP, THD_WAIT_DISKIO, THD_WAIT_ROW_LOCK 처럼 11가지로 나뉩니다(include/mysql/service_thd_wait.h 67–77행).

스레드 풀에서 이 알림은 결국 wait_begin() 에 닿습니다.

void wait_begin(thread_group_t *thread_group)
{
  DBUG_ENTER("wait_begin");
  mysql_mutex_lock(&thread_group->mutex);
  thread_group->active_thread_count--;

  DBUG_ASSERT(thread_group->active_thread_count >=0);
  DBUG_ASSERT(thread_group->connection_count > 0);

  if ((thread_group->active_thread_count == 0) &&
     (!is_queue_empty(thread_group) || !thread_group->listener))
  {
    /*
      Group might stall while this thread waits, thus wake
      or create a worker to prevent stall.
    */
    wake_or_create_thread(thread_group);
  }

  mysql_mutex_unlock(&thread_group->mutex);
  DBUG_VOID_RETURN;
}

sql/threadpool_generic.cc 1283–1304행

기다리기 시작한 워커는 "활동 중"에서 빠지고, 그 결과 그룹에 활동 중인 워커가 하나도 없는데 큐에 일이 있거나 리스너가 없으면 즉시 다른 워커를 깨우거나 만듭니다(wake_or_create_thread(), 946–986행 — 대기 목록에서 깨울 수 있으면 깨우고, 없으면 새로 만들되 그룹의 스레드가 많을수록 만드는 간격을 늘립니다). 6절의 예제 5에서 로그인 20개가 워커 17개를 만든 것도 이 길입니다. 같은 예제의 3단계에서는 SELECT SLEEP(4) 6개를 동시에 보냈고, THREAD_POOL_WAITS 에 SLEEP 6번이 기록되며 워커가 다시 8개로 늘었습니다.

--- 3. six clients running SELECT SLEEP(4) at the same time
      1 timer_thread
      8 worker_thread
...
+--------+-------+
 REASON | COUNT |
+--------+-------+
 SLEEP  |     6 |
 NET    |    92 |
+--------+-------+

(가운데의 그룹 표와 THREAD_POOL_STATS 는 일부만 실었습니다. NET 은 서버가 뜬 뒤 쌓인 네트워크 대기 수로, 실행마다 다릅니다.)

둘째 겹: 알리지 않는 쪽은 timer_thread 가 잡는다

CPU 만 쓰는 긴 질의는 아무것도 기다리지 않으므로 알림도 없습니다. 이때를 위해 풀에는 timer_thread 가 하나 있고, thread_pool_stall_limit(기본 500ms, DEFAULT_THREADPOOL_STALL_LIMIT)마다 깨어나 연결이 있는 모든 그룹에 check_stall() 을 부릅니다(573–590행). 검사는 두 가지입니다.

  if (!thread_group->listener && !thread_group->io_event_count)
  {
    wake_or_create_thread(thread_group, true);
    mysql_mutex_unlock(&thread_group->mutex);
    return;
  }
  // ...
  if (!is_queue_empty(thread_group) && !thread_group->queue_event_count)
  {
    thread_group->stalled= true;
    TP_INCREMENT_GROUP_COUNTER(thread_group,stalls);
    wake_or_create_thread(thread_group,true);
  }

sql/threadpool_generic.cc 639–680행

  1. 리스너가 없고, 지난 틱 이후 받은 네트워크 이벤트도 없으면 워커를 깨웁니다. 6절에서 본 "리스너가 이벤트를 직접 맡아 자리를 비운" 경우가 이것입니다.
  2. 큐에 일이 있는데 지난 틱 이후 아무도 큐에서 꺼내지 않았으면 그룹을 "멈췄다(stalled)"고 표시하고 워커를 깨웁니다. stalled 인 동안에는 too_many_threads() 의 제한도 풀립니다.

두 카운터(io_event_count, queue_event_count)는 틱마다 0으로 되돌려 "이번 틱 사이에 움직임이 있었나"를 봅니다. 그래서 thread_pool_stall_limit 은 "이 시간 동안 아무 진전이 없으면 멈춘 것으로 본다"는 간격입니다. 같은 timer_thread 가 wait_timeout 이 지난 놀고 있는 연결도 찾아 KILL_WAIT_TIMEOUT 으로 끊습니다(timeout_check() → tp_timeout_handler(), 508–536행, threadpool_common.cc 543–567행).

예제 6: BENCHMARK 와 SLEEP 은 다르게 대접받는다

그룹을 하나로(--thread-pool-size=1), 틱을 3초로(--thread-pool-stall-limit=3000) 두고, 한 연결이 약 8초 걸리는 문장을 도는 동안 다른 클라이언트가 SELECT 1 을 보내는 데 걸리는 시간(접속 포함)을 쟀습니다(ex/u04-pool-stall). 긴 문장은 CPU 만 쓰는 BENCHMARK(6000000, MD5('namalzi')) 와, 기다림을 알리는 SLEEP(8) 두 가지입니다.

# 이 글을 위해 쓴 예제입니다 (MariaDB 의 소스가 아닙니다)
# ...
lab-start --thread-handling=pool-of-threads --thread-pool-size=1 --thread-pool-stall-limit=3000 > /dev/null
# ...
mariadb -e "FLUSH THREAD_POOL_STATS"
mariadb -N -e "SELECT BENCHMARK(6000000, MD5('namalzi'))" > /dev/null &
sleep 1
timed "SELECT 1"
# ...

[예제 ex/u04-pool-stall/run.sh 에서 줄임 — timed 는 date +%s.%N 으로 앞뒤 시각을 재는 셸 함수]

--- 1. while another client runs BENCHMARK (CPU only, about 8 s)
SELECT 1   took 2.8 s
+----------+------------------+-------------------------------+-------+--------------------+--------+
 GROUP_ID | THREAD_CREATIONS | THREAD_CREATIONS_DUE_TO_STALL | WAKES | WAKES_DUE_TO_STALL | STALLS |
+----------+------------------+-------------------------------+-------+--------------------+--------+
        0 |                0 |                             0 |     2 |                  1 |      0 |
+----------+------------------+-------------------------------+-------+--------------------+--------+
--- 2. while another client runs SLEEP(8)
SELECT 1   took 0.2 s
+----------+------------------+-------------------------------+-------+--------------------+--------+
 GROUP_ID | THREAD_CREATIONS | THREAD_CREATIONS_DUE_TO_STALL | WAKES | WAKES_DUE_TO_STALL | STALLS |
+----------+------------------+-------------------------------+-------+--------------------+--------+
        0 |                1 |                             0 |     3 |                  0 |      0 |
+----------+------------------+-------------------------------+-------+--------------------+--------+

BENCHMARK 옆의 SELECT 1 은 2.8초, SLEEP 옆에서는 0.2초였습니다(시간은 실행마다 다르고 — 다시 돌렸을 때는 3.5초와 0.1초 — Debug 빌드라 느립니다). FLUSH THREAD_POOL_STATS 는 thread_pool_info.cc 의 stats_reset_table() 로 카운터를 0 으로 되돌립니다(212–225행).

스레드 풀이 멈추는 과정의 순서도. 연결 X의 BENCHMARK 패킷을 리스너였던 워커 A가 큐가 비어 있어 직접 맡고, 그룹에 리스너가 없어진다. 워커 A는 CPU만 쓰고 대기 알림을 보내지 않는다. 연결 Y가 새로 접속하면 로그인 일감이 큐에 들어가지만 도는 워커가 있으니 아무도 깨우지 않는다. timer_thread의 첫 틱에서는 직전 틱 뒤에 받은 이벤트가 있었으므로 넘어가고, 다음 틱에서 리스너도 없고 이벤트도 0이므로 멈춤으로 보고 wake_or_create_thread(due_to_stall=true)로 대기 목록의 워커 B를 깨운다. 워커 B가 큐에서 Y를 꺼내 로그인하고 SELECT 1을 처리한다. SLEEP이었다면 thd_wait_begin이 바로 워커 B를 깨웠다. BENCHMARK 쪽에서 WAKES_DUE_TO_STALL 이 1, STALLS 가 0 인 것이 이 그림의 경로(check_stall 의 첫째 검사)를 탔다는 표시입니다.

헷갈리기 쉬운 곳: STALLS 가 0 인데 멈췄던 것이다

예제 6의 첫 표에서 STALLS 는 0입니다. 그래서 "멈춘 적이 없다"고 읽기 쉽지만, WAKES_DUE_TO_STALL 은 1입니다. check_stall() 의 첫째 검사(리스너 없음)는 wake_or_create_thread(thread_group, true) 를 부르고 stalls 카운터를 올리기 전에 return 합니다. stalls 는 둘째 검사(큐가 움직이지 않음)에서만 올라갑니다. 새 연결 Y 의 로그인 일감은 큐에 들어가 있었지만, 첫째 검사가 먼저 걸려 깨운 워커가 그것을 꺼내 갔습니다. 정체 여부를 모니터링할 때는 STALLS 하나만 보지 말고 WAKES_DUE_TO_STALL·THREAD_CREATIONS_DUE_TO_STALL 을 같이 봐야 하는 이유입니다.

정리하면 스레드 풀에서 알림 없이 CPU 를 오래 쓰는 질의는 같은 그룹의 다른 연결을 최대 틱 한두 번만큼 늦출 수 있습니다. thread_pool_stall_limit 을 너무 크게 잡으면 이 지연이 커지고, 너무 작게 잡으면 워커가 쉽게 늘어납니다. check_stall() 위의 주석도 긴 질의가 계속 쏟아지면 스레드 풀이 "천천히 연결마다 스레드 쪽에 가까워진다"고 적어 두었습니다(662–668행).

8. KILL 은 기다리는 연결을 어떻게 깨우나

KILL <id> 는 대상 THD 의 awake_no_mutex() 를 부릅니다(sql_parse.cc 9229행). 이 함수는 killed 표시를 세운 뒤 연결을 끊는 종류의 KILL 이면 스케줄러에 알립니다.

    /* Send an event to the scheduler that a thread should be killed. */
    if (!slave_thread)
      MYSQL_CALLBACK(scheduler, post_kill_notification, (this));

sql/sql_class.cc 2160–2162행

두 모드 모두 결국 같은 함수에 닿습니다.

void post_kill_notification(THD *thd)
{
  DBUG_ENTER("post_kill_notification");
  if (current_thd == thd || thd->system_thread)
    DBUG_VOID_RETURN;

  if (thd->net.vio)
    vio_shutdown(thd->net.vio, SHUT_RD);
  DBUG_VOID_RETURN;
}

sql/scheduler.cc 92–101행

소켓의 읽기 쪽을 닫습니다(SHUT_RD). 사실 Linux 빌드에서는 그 전에 한 번 더 닫힙니다. SIGNAL_WITH_VIO_CLOSE 가 켜져 있어서(configure.cmake 844행) awake_no_mutex() 가 위 인용 바로 앞에서 로그인 때 기억해 둔 active_vio 를 vio_shutdown(active_vio, SHUT_RDWR) 로 먼저 닫기 때문입니다(sql_class.cc 2152–2158행, active_vio 는 check_connection() 이 1063행에서 정해 둡니다). 어느 쪽이 먼저든 결과는 같습니다. 기본 모드에서는 5절에서 본 poll() 이 바로 깨어나 읽기가 실패하고, do_command() 가 끝나며 연결이 정리됩니다. 스레드 풀에서는 소켓이 epoll 에서 읽을 수 있는 상태(끝)로 바뀌어 리스너가 그 연결을 큐에 넣고, 워커가 threadpool_process_request() 에서 thd->killed >= KILL_CONNECTION 을 보고 연결을 닫습니다(408–418행). 스레드 풀의 tp_post_kill_notification() 은 그 전에 연결의 우선순위를 높음으로 올려(599–605행) 죽일 연결이 큐에서 오래 기다리지 않게 합니다. 엔진 안에서 기다리는 경우(예: 행 잠금)는 awake_no_mutex() 가 이어서 ha_kill_query() 로 엔진에게 따로 알립니다(2166–2169행).

KILL 이 기다리는 연결을 깨우는 길. KILL (sql_parse.cc 9229행)가 대상 THD 의 awake_no_mutex() 를 부르면 killed 표시를 세우고, Linux 빌드에서는 SIGNAL_WITH_VIO_CLOSE 때문에 vio_shutdown(active_vio, SHUT_RDWR) 로 먼저 소켓을 닫고, 이어서 scheduler->post_kill_notification() 이 vio_shutdown(net.vio, SHUT_RD) 를 부른다(스레드 풀은 그 전에 우선순위를 높음으로 올린다). 엔진 안에서 기다리면 ha_kill_query() 로 엔진에 따로 알린다. 기본 모드에서는 do_command() 안의 poll() 이 바로 깨어 읽기가 실패하고 do_command() 가 끝나 연결이 정리된다. 스레드 풀에서는 소켓이 epoll 에서 읽을 수 있는 상태(끝)가 되어 리스너가 연결을 큐에 넣고, 워커의 threadpool_process_request() 가 killed >= KILL_CONNECTION 을 보고 연결을 닫는다 KILL 은 상대 스레드를 직접 멈추지 않고, 표시를 세운 뒤 그 스레드가 기다리는 소켓을 닫아 스스로 깨게 합니다. 줄 번호는 위 문단과 sql_class.cc 2148–2169행에서 확인했습니다.

앞 연재와 견주어 보기

같은 질문 — "연결은 어떤 스레드가 받고, 놀고 있는 연결은 누가 붙잡나" — 에 세 서버가 낸 답을 나란히 놓으면 이렇습니다.

httpd (8편) Tomcat (5편) MariaDB (이 글)
갈아 끼우는 단위 MPM 모듈 (LoadModule 로 하나) main 에서는 NIO 하나 스케줄러 구조체 (thread_handling)
접속을 받는 스레드 자식의 리스너 스레드 Acceptor 1개 메인 스레드 1개 (handle_connections_sockets)
"연결마다 스레드" 방식 worker (keep-alive 동안 스레드를 붙잡음) — one-thread-per-connection (Linux 기본)
놀고 있는 연결을 지켜보는 것 event 의 리스너 (pollset) Poller 1개 (Selector) 그룹마다 리스너 1개 (epoll), 워커가 돌아가며 맡음
일하는 스레드 작업 스레드 (fd_queue_t 에서 꺼냄) 작업 스레드 풀 (TaskQueue) 워커 (그룹의 큐 둘에서 꺼냄)
일꾼 수 조절 부모가 1초마다 스코어보드를 센다 ThreadPoolExecutor thd_wait_begin() 알림 + timer_thread 의 정체 감지

MariaDB 의 스레드 풀은 event MPM 이나 Tomcat 의 Poller 와 같은 생각 — 요청과 요청 사이에 기다리는 연결을 스레드에서 떼어 소수의 스레드가 epoll 로 지켜보게 한다 — 을 데이터베이스에 맞게 바꾼 것입니다. 다른 점도 분명합니다. HTTP 서버의 요청은 대개 짧지만 SQL 문장은 몇 분씩 걸리고 잠금을 기다리기도 합니다. 그래서 MariaDB 는 "기다리기 시작한다"는 알림(thd_wait_begin())을 엔진 깊숙한 곳까지 심어 두고, 알림이 없는 긴 질의는 timer_thread 가 잡아내는 두 겹의 장치를 두었습니다. 또 리스너가 따로 있는 스레드가 아니라 워커가 돌아가며 맡는 역할이라는 점, 연결이 처음부터 끝까지 한 그룹에 묶인다는 점이 Tomcat 과 다릅니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
메인 스레드 handle_connections_sockets() 에서 poll() → accept() → CONNECT → create_new_thread(). 클라이언트와 대화하지 않는다
CONNECT 소켓·스케줄러·연결 번호만 든 "THD 가 생기기 전의 연결"
max_connections 메인 스레드가 +1 까지 대강 막고, 인증 뒤 CONNECTION ADMIN 이 없으면 정확히 막는다. 오류 모양이 다르다
scheduler_functions add_connection·thd_wait_begin·post_kill_notification 등 함수 포인터 묶음. thread_handling 이 채울 함수를 고른다
thread_handling Linux 기본 one-thread-per-connection, Windows 기본 pool-of-threads. 읽기 전용. extra_port 는 늘 연결마다 스레드
handle_one_connection() 로그인 → do_command() 반복 → 정리 → thread_cache.park(). THD 까지 다시 쓴다
스레드 캐시 대기 줄 + 조건 변수. 5분 놀면 끝. thread_cache_size 는 max_connections 를 넘지 않게 줄어든다
로그인 create_thd() → thd_prepare_connection() → check_connection() → acl_authenticate() → 인증 플러그인
스레드 풀 연결 번호 % 그룹 수로 그룹에 묶고, 그룹마다 epoll·큐 둘·리스너 하나·워커 여럿
get_event() 큐 → 리스너 되기 → epoll_wait(0) → 대기 목록 맨 앞에서 잠들기
EPOLLONESHOT 처리 중인 연결을 다른 워커가 집지 않게 하고, 끝나면 start_io() 로 다시 건다
thd_wait_begin() 잠금·디스크·SLEEP()·네트워크 대기를 풀에 알려 바로 다른 워커를 깨운다
timer_thread thread_pool_stall_limit 마다 리스너 없음·큐 정체를 검사하고 wait_timeout 도 처리한다
KILL post_kill_notification() 이 소켓 읽기 쪽을 닫아 기다리는 곳을 깨운다

다음에 볼 것

연결 스레드(또는 풀의 워커)가 do_command() 에 들어서는 데까지 왔습니다. 다음 글은 그 안으로 들어갑니다. 5절의 백트레이스에서 이름만 지나간 my_net_read_packet() 이 3바이트 길이와 1바이트 순번으로 패킷을 자르는 방법, 명령 바이트 하나로 COM_QUERY·COM_PING·COM_QUIT 을 가르는 dispatch_command() 의 큰 switch, 그리고 결과를 돌려보내는 Protocol 이 주인공입니다. 5편: 프로토콜과 명령 처리 — 패킷 하나가 dispatch_command에 닿기까지에서 이어집니다.

httpd 연재의 8편: MPM과 Tomcat 연재의 5편: 커넥터와 NIO 엔드포인트는 같은 질문에 대한 웹 서버의 답입니다. 위의 비교 표를 들고 두 글을 읽어 보면, "놀고 있는 연결을 스레드에서 떼어 낸다"는 같은 생각이 세 서버에서 어떻게 다른 모양이 되었는지 보입니다. 애플리케이션 쪽에서 이 연결들을 미리 열어 두고 빌려 쓰는 쪽은 Spring 18편: JdbcTemplate의 DataSource 입니다.

다른 글