안녕하세요. 입개말만 하는 CharSyam 입니다. 이번에 Redis Version 6.0.x 가 출시되었습니다. Redis 5.0에서도 Stream 등 새로운 기능이 들어왔었는데, 이번 6.0에서도
ACL 및 여러가지 기능들이 들어왔습니다. 그 중에서도 많은 사람들이 관심있어 하는 것이 바로 ThreadedIO 입니다. Redis를 쓰는 사람들은 Redis에 가졌던 많은 불만 중에 하나가
왜 Multi Thread를 지원하지 않는가였습니다. 그러면 성능이 훨씬 높아질텐데라는 생각을 하면서요. 반대로 Multi Thread를 지원하면 기존의 Redis의 특징중에 하나였던 Atomic을
어떻게 보장할 것인가도 의문이이었습니다.
그렇다면 지금 Redis 6.0에서는 이걸 어떻게 지원했는가? 그리고 어떻게 동작하는가가 이번 잡글의 주제입니다.
다음은 Redis 커미터인 Antirez의 트윗입니다. Redis의 ThreadedIO는 복잡하지 않으면서도 2.5배 정도 빨라졌다는 것입니다. 그렇다면 어떻게 이렇게 됬을까요?
일단 결론부터 말씀드리면, Redis 의 ThreadedIO가 적용되는 부분은 다음과 같습니다.
- 클라이언트가 전송한 명령을 네트웍으로 읽어서 파싱하는 부분
- 명령이 처리된 결과 메시지를 클라이언트에게 네트웍으로 전달하는 부분
위의 두 개에서 여전히 명령의 실행 자체는 빠져 있습니다. 넵 Redis 6.0의 ThreadedIO는 여전히 명령의 실행은 Single Threaded 입니다. 즉 기존에 문제가 되던 Atomic이 깨지지 않았다는 것입니다.
그렇다면 Redis에서 Threaded IO 가 어떻게 구현되었는지 살펴보도록 하겠습니다.
먼저 이전까지 완벽한(?) Single Threaded 형식의 Redis Event Loop 입니다. 즉 여기서는 하나의 이벤트 루프에서 IO Multiplexing을 이용해서 Read/Write 이벤트를 받아오고, Read 이벤트가 발생하면 네트웍에서 패킷을 읽고, 명령이 완성되면 실행이 되었습니다.
그런데 새로운 Redis 6의 Threaded IO는 조금 모양이 다릅니다. 실제로 IO Multiplexing 작업을 하고 나면, 이 이벤트들이 발생한 클라이언트를 다음과 같은 리스트에 저장합니다.
(여기서 write는 조금 복잡합니다. IO Multiplexing에 의해서 Write Event 가 발생해서 해당 리스트에 저장하는게 아니라, addReply 계열이 호출되었을 때, clients_pending_write에 저장되게 됩니다.)
- Read 이벤트 : server.clients_pending_read
- Write 이벤트 : server.clients_pending_write
그리고 이제 beforeSleep에서 (beforeSleep은 매틱 마다 IO Multiplexing 전에 호출 되는 함수입니다.) 다음과 같은 두 개의 함수를 호출합니다.
- handleClientsWithPendingReadsUsingThreads
- handleClientsWithPendingWritesUsingThreads
/* This function gets called every time Redis is entering the * main loop of the event driven library, that is, before to sleep * for ready file descriptors. */ void beforeSleep(struct aeEventLoop *eventLoop) { UNUSED(eventLoop); ...... /* We should handle pending reads clients ASAP after event loop. */ handleClientsWithPendingReadsUsingThreads(); ...... /* Handle writes with pending output buffers. */ handleClientsWithPendingWritesUsingThreads(); ...... }
먼저 handleClientsWithPendingReadsUsingThreads 함수 부터 알아보도록 하겠습니다. 주석을 추가합니다.
먼저 clients_pending_read 리스트에서 하나씩 항목을 가져옵니다. 하나 처리할 때 마다 item_id 를 증가합니다.
해당 클라이언트를 다음 공식에 의해서 io_threads에 할당합니다.
target_id = item_id % server.io_threads_num
그리고 해당 스레드들이 작업을 하도록 trigger를 켜고, 작업이 완료되길 기다립니다.
여기서는 Read 이벤트들만 처리가 됩니다. IO 스레드들의 모든 작업이 끝나면, 이제 다시 client_pending_read 리스트를 순회하면서, 명령이 완성되었으면 명령을 처리합니다. 여기서 processCommand가 모두 처리되므로, Atomic이 유지됩니다.
/* When threaded I/O is also enabled for the reading + parsing side, the * readable handler will just put normal clients into a queue of clients to * process (instead of serving them synchronously). This function runs * the queue using the I/O threads, and process them in order to accumulate * the reads in the buffers, and also parse the first command available * rendering it in the client structures. */ int handleClientsWithPendingReadsUsingThreads(void) { if (!io_threads_active || !server.io_threads_do_reads) return 0; int processed = listLength(server.clients_pending_read); if (processed == 0) return 0; if (tio_debug) printf("%d TOTAL READ pending clients\n", processed); /* Distribute the clients across N different lists. */ listIter li; listNode *ln; listRewind(server.clients_pending_read,&li); int item_id = 0; while((ln = listNext(&li))) { client *c = listNodeValue(ln); //해당 공식을 통해서 특정 스레드에서 처리되도록 할당된다. int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); //Round Robin 방식으로 스레드에 할당 item_id++; } /* Give the start condition to the waiting threads, by setting the * start condition atomic var. */ io_threads_op = IO_THREADS_OP_READ; for (int j = 1; j < server.io_threads_num; j++) { int count = listLength(io_threads_list[j]); //io_threads 는 각각의 io_threads_pending 값이 1보다 크면 동작하게 되므로 실행을 Trigger 한다. io_threads_pending[j] = count; } /* Also use the main thread to process a slice of clients. */ // io_threads_list[0] 번은 현재의 main thread, main thread도 일하도록 균등하게 할당됨. listRewind(io_threads_list[0],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); readQueryFromClient(c->conn); } listEmpty(io_threads_list[0]); /* Wait for all the other threads to end their work. */ //모든 스레드의 작업이 끝나길 대기한다. 위에서 io_threds_pending의 값이 trigger 였다는 걸 기억하자. //0이되면 스레드의 작업이 종료된 것이다. while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; } if (tio_debug) printf("I/O READ All threads finshed\n"); /* Run the list of clients again to process the new buffers. */ while(listLength(server.clients_pending_read)) { //IO 스레드의 작업으로 명령이 파싱된 것들은 모두 CLIENT_PENDING_COMMAND가 붙게 된다. //이제 Main Thread에서는 이렇게 붙은 애들만 처리하면 된다. ln = listFirst(server.clients_pending_read); client *c = listNodeValue(ln); c->flags &= ~CLIENT_PENDING_READ; listDelNode(server.clients_pending_read,ln); if (c->flags & CLIENT_PENDING_COMMAND) { c->flags &= ~CLIENT_PENDING_COMMAND; if (processCommandAndResetClient(c) == C_ERR) { /* If the client is no longer valid, we avoid * processing the client later. So we just go * to the next. */ continue; } } //psync2 때문에 추가된 부분 https://github.com/antirez/redis/commit/4447ddc8bb36879db9fe49498165b360bf35ba1b //그런데 이 코드가 없었으면, 위에서 하나의 패킷에 여러개의 명령이 들어왔다면, 뒤의 명령이, 다음 패킷이 들어올때 까지 처리되지 않는 이슈가 //있었을 것으로 보인다. processInputBuffer(c); } return processed; }
handleClientsWithPendingWritesUsingThreads 함수도 거의 동일한 로직입니다.
int handleClientsWithPendingWritesUsingThreads(void) { int processed = listLength(server.clients_pending_write); if (processed == 0) return 0; /* Return ASAP if there are no clients. */ /* If I/O threads are disabled or we have few clients to serve, don't * use I/O threads, but thejboring synchronous code. */ if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) { return handleClientsWithPendingWrites(); } /* Start threads if needed. */ if (!io_threads_active) startThreadedIO(); if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed); /* Distribute the clients across N different lists. */ listIter li; listNode *ln; listRewind(server.clients_pending_write,&li); int item_id = 0; //같은 로직으로 타켓 클라이언트를 IO 스레드 큐에 분배합니다. while((ln = listNext(&li))) { client *c = listNodeValue(ln); c->flags &= ~CLIENT_PENDING_WRITE; int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); item_id++; } /* Give the start condition to the waiting threads, by setting the * start condition atomic var. */ // Write 이벤트만 처리한다고 설정합니다. io_threads_op = IO_THREADS_OP_WRITE; for (int j = 1; j < server.io_threads_num; j++) { int count = listLength(io_threads_list[j]); io_threads_pending[j] = count; } /* Also use the main thread to process a slice of clients. */ listRewind(io_threads_list[0],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); writeToClient(c,0); } listEmpty(io_threads_list[0]); /* Wait for all the other threads to end their work. */ while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; } if (tio_debug) printf("I/O WRITE All threads finshed\n"); /* Run the list of clients again to install the write handler where * needed. */ listRewind(server.clients_pending_write,&li); //처리하지 못한 부분은 여기서 다시 handler를 걸어줍니다. 이 녀석들은 다음번 IO Multiplexing 때에 실행됩니다. while((ln = listNext(&li))) { client *c = listNodeValue(ln); /* Install the write handler if there are pending writes in some * of the clients. */ if (clientHasPendingReplies(c) && connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR) { freeClientAsync(c); } } listEmpty(server.clients_pending_write); return processed; }
여기서 재미있는 부분 중에 하나는 io_threads_op 입니다. Redis의 IO Thread 들은 이 변수에 지정된 타입만 수행을 합니다. 코드를 보시면
list *io_threads_list[IO_THREADS_MAX_NUM]; void *IOThreadMain(void *myid) { /* The ID is the thread number (from 0 to server.iothreads_num-1), and is * used by the thread to just manipulate a single sub-array of clients. */ long id = (unsigned long)myid; char thdname[16]; snprintf(thdname, sizeof(thdname), "io_thd_%ld", id); redis_set_thread_title(thdname); redisSetCpuAffinity(server.server_cpulist); while(1) { /* Wait for start */ for (int j = 0; j < 1000000; j++) { if (io_threads_pending[id] != 0) break; } /* Give the main thread a chance to stop this thread. */ //io_threads_pending 값을 보고 작업을 수행한다. 0보다 커야만 동작 if (io_threads_pending[id] == 0) { pthread_mutex_lock(&io_threads_mutex[id]); pthread_mutex_unlock(&io_threads_mutex[id]); continue; } serverAssert(io_threads_pending[id] != 0); if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id])); /* Process: note that the main thread will never touch our list * before we drop the pending count to 0. */ listIter li; listNode *ln; listRewind(io_threads_list[id],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); //io_threads_op를 보고 write를 할지, read를 할지 결정한다. if (io_threads_op == IO_THREADS_OP_WRITE) { writeToClient(c,0); } else if (io_threads_op == IO_THREADS_OP_READ) { readQueryFromClient(c->conn); } else { serverPanic("io_threads_op value is unknown"); } } listEmpty(io_threads_list[id]); io_threads_pending[id] = 0; if (tio_debug) printf("[%ld] Done\n", id); } }
저 부분들이 어떻게 client_pending_read, client_pending_write 에 들어가는지는 좀 복잡합니다. 다음번에 쓸지 안쓸지는 ㅎㅎㅎ…
코드를 읽다보니, 조금 애매한 부분들이 있긴 한데, 실제로 성능이 2,3배 좋아졌다고 하니…
정리하면, Redis 의 ThreadedIO가 적용되는 부분은 다음과 같습니다. 그리고 그 구조상 ProcessCommand는 여전히 main thread에서만 실행되기 때문에 Atomic 합니다.
다시 Redis에서 ThreadedIO가 적용되는 부분은 다음과 같습니다. Redis 5에서는 lazy free등이 설정에 따라서 Thread로 동작하긴합니다.
- 클라이언트가 전송한 명령을 네트웍으로 읽어서 파싱하는 부분
- 명령이 처리된 결과 메시지를 클라이언트에게 네트웍으로 전달하는 부분
그런데 이 부분이 도입되고 왜 반응성이 좋아졌는가? 실제로 명령이 수행되는 스레드는 한개가 아닌가? 라는 질문이 생길껍니다. 제 생각(?)을 말씀드리자면, 그 만큼 레디스가 많이 사용될때는
클라이언트들의 리퀘스트가 처리되지 않고 네트웍에 대기하고 있거나, 수행되었지만, 응답을 받지 못해서 늦게 처리되는 부분들이 줄어들어서라고 생각합니다. 레디스를 사용하다보면, Redis에는
slow log가 없지만, 클라이언트 단에서는 200~300ms 이상 걸릴때가 종종 생기는데, 이런 이슈가 줄어들것으로 보이고, 여기서 걸리던 시간 만큼 더 명령을 처리하게 되었으니… 실제 처리량도
늘어날듯 합니다.(스레드를 더 쓰니, 더 속도가 빨라지긴 해야 ㅎㅎㅎ)
그럼 모두들 고운하루되세요. Antirez의 말대로 코드량은 작은대, 중복으로 호출되면서 Flags에 따라서 그냥 설정만 하고 리턴하는 부분들이 있어서… 좀 코드는 까다롭네요.
write처리도 대규모 write가 발생할 때는 좀 비효울적인 부분이 있을 수 있는…