
作者:李樂
來源:IT閱讀排行榜
很多人都遇到過這么一道面試題:Redis是單線程還是多線程?這個問題既簡單又復雜。說他簡單是因為大多數人都知道Redis是單線程,說復雜是因為這個答案其實并不準確。
難道Redis不是單線程?我們啟動一個Redis實例,驗證一下就知道了。Redis安裝部署方式如下所示:
//?下載
wget?https://download.redis.io/redis-stable.tar.gz
tar?-xzvf?redis-stable.tar.gz
//?編譯安裝
cd?redis-stable
make
//?驗證是否安裝成功
./src/redis-server?-v
Redis?server?v=7.2.4
接下來啟動Redis實例,使用命令ps查看所有線程,如下所示:
//?啟動Redis實例
./src/redis-server?./redis.conf
//?查看實例進程ID
ps?aux?|?grep?redis
root?????385806??0.0??0.0?245472?11200?pts/2????Sl+??17:32???0:00?./src/redis-server?127.0.0.1:6379
//?查看所有線程
ps?-L?-p?385806
???PID????LWP?TTY??????????TIME?CMD
385806?385806?pts/2????00:00:00?redis-server
385806?385809?pts/2????00:00:00?bio_close_file
385806?385810?pts/2????00:00:00?bio_aof
385806?385811?pts/2????00:00:00?bio_lazy_free
385806?385812?pts/2????00:00:00?jemalloc_bg_thd
385806?385813?pts/2????00:00:00?jemalloc_bg_thd
竟然有6個線程!不是說Redis是單線程嗎?怎么會有這么多線程呢?
這6個線程的含義你可能不太了解,但是通過這個示例至少說明Redis并不是單線程。
接下來我們逐個介紹上述6個線程的作用:
1)redis-server:
主線程,用于接收并處理客戶端請求。
2)jemalloc_bg_thd
jemalloc 是新一代的內存分配器,Redis底層使用他管理內存。
3)bio_xxx:
以bio前綴開始的都是異步線程,用于異步執行一些耗時任務。其中,線程bio_close_file用于異步刪除文件,線程bio_aof用于異步將AOF文件刷到磁盤,線程bio_lazy_free用于異步刪除數據(懶刪除)。
需要說明的是,主線程是通過隊列將任務分發給異步線程的,并且這一操作是需要加鎖的。主線程與異步線程的關系如下圖所示:

這里我們以懶刪除為例,講解為什么要使用異步線程。Redis是一款內存數據庫,支持多種數據類型,包括字符串、列表、哈希表、集合等。思考一下,刪除(DEL)列表類型數據的流程是怎樣的呢?第一步從數據庫字典中刪除該鍵值對,第二步遍歷并刪除列表中的所有元素(釋放內存)。想想如果列表中的元素數目非常多呢?這一步將非常耗時。這種刪除方式稱為同步刪除,流程如下圖所示:

針對上述問題,Redis提出了懶刪除(異步刪除),主線程在收到刪除命令(UNLINK)時,首先從數據庫字典中刪除該鍵值對,隨后再將刪除任務分發給異步線程bio_lazy_free,由異步線程執行第二步耗時邏輯。這時候的流程如下圖所示:

難道Redis是多線程?那為什么我們老說Redis是單線程呢?這是因為讀取客戶端命令請求,執行命令以及向客戶端返回結果都是在主線程完成的。不然的話,多線程同時操作內存數據庫,并發問題如何解決?如果每次操作之前都加鎖,那和單線程又有什么區別呢?
當然這一流程在Redis6.0版本也發生了改變,Redis官方指出,Redis是基于內存的鍵值對數據庫,執行命令的過程是非常快的,讀取客戶端命令請求和向客戶端返回結果(即網絡I/O)通常會成為Redis的性能瓶頸。
因此,在Redis 6.0版本,作者加入了多線程I/O的能力,即可以開啟多個I/O線程,并行讀取客戶端命令請求,并行向客戶端返回結果。I/O多線程能力使得Redis性能提升至少一倍。
為了開啟多線程I/O能力,需要先修改配置文件redis.conf:
io-threads-do-reads?yes
io-threads?4
這兩個配置含義如下:
io-threads-do-reads:是否開啟多線程I/O能力,默認為"no";
io-threads:I/O線程數目,默認為1,即只使用主線程執行網絡I/O,線程數最大為128;該配置應該根據CPU核數設置,作者建議,4核CPU設置2~3個I/O線程,8核CPU設置6個I/O線程。
開啟多線程I/O能力之后,重新啟動Redis實例,查看所有線程,結果如下:
ps?-L?-p?104648
???PID????LWP?TTY??????????TIME?CMD
104648?104648?pts/1????00:00:00?redis-server
104648?104654?pts/1????00:00:00?io_thd_1
104648?104655?pts/1????00:00:00?io_thd_2
104648?104656?pts/1????00:00:00?io_thd_3
……
由于我們設置了io-threads等于4,所以會創建4個線程用于執行I/O操作(包括主線程),上述結果符合預期。
當然,只有I/O階段才使用了多線程,處理命令請求還是單線程,畢竟多線程操作內存數據存在并發問題。
最后,開啟了I/O多線程之后,命令的執行流程如下圖所示:

Redis還有多進程?是的。在某些場景下,Redis也會創建多個子進程來執行一些任務。以持久化為例,Redis支持兩種類型的持久化:
AOF(Append Only File):可以看作是命令的日志文件,Redis會將每一個寫命令都追加到AOF文件。
RDB(Redis Database):以快照的方式存儲Redis內存中的數據。命令SAVE用于手動觸發RDB持久化。想想如果Redis中的數據量非常大,持久化操作必然耗時比較長,而Redis是單線程處理命令請求,那么當命令SAVE的執行時間過長時,必然會影響其他命令的執行。
命令SAVE有可能會阻塞其他請求,為此,Redis又引入了命令BGSAVE,該命令會創建一個子進程來執行持久化操作,這樣就不會影響主進程執行其他請求了。
我們可以手動執行命令BGSAVE驗證。首先,使用GDB跟蹤Redis進程,添加斷點,讓子進程阻塞在持久化邏輯。如下所示:
//?查詢Redis進程ID
ps?aux?|?grep?redis
root?????448144??0.1??0.0?270060?11520?pts/1????tl+??17:00???0:00?./src/redis-server?127.0.0.1:6379
//?GDB跟蹤進程
gdb?-p?448144
//?跟蹤創建的子進程(默認GDB只跟蹤主進程,需手動設置)
(gdb)?set?follow-fork-mode?child
//?函數rdbSaveDb用于持久化數據快照
(gdb)?b?rdbSaveDb
Breakpoint?1?at?0x541a10:?file?rdb.c,?line?1300.
(gdb)?c
設置好斷點之后,使用Redis客戶端發送命令BGSAVE,結果如下:
//?請求立即返回
127.0.0.1:6379>?bgsave
Background?saving?started
//?GDB輸出以下信息
[New?process?452541]
Breakpoint?1,?rdbSaveDb?(...)?at?rdb.c:1300
可以看到,GDB目前跟蹤的是子進程,進程ID是452541。也可以通過Linux命令 ps 查看所有進程,結果如下:
ps?aux?|?grep?redis
root?????448144??0.0??0.0?270060?11520?pts/1????Sl+??17:00???0:00?./src/redis-server?127.0.0.1:6379
root?????452541??0.0??0.0?270064?11412?pts/1????t+???17:19???0:00?redis-rdb-bgsave?127.0.0.1:6379
可以看到子進程的名稱是redis-rdb-bgsave,也就是該進程將所有數據的快照持久化在RDB文件。
最后再思考兩個問題。
問題1:為什么采用子進程而不是子線程呢?
因為RDB是將數據快照持久化存儲,如果采用子線程,主線程與子線程將會共享內存數據,主線程在持久化的同時還會修改內存數據,這有可能導致數據不一致。而主進程與子進程的內存數據是完全隔離的,不存在此問題。
問題2:假設Redis內存中存儲了10GB的數據,在創建子進程執行持久化操作之后,此時子進程也需要10GB的內存嗎?復制10GB的內存數據,也會比較耗時吧?另外如果系統只有15GB的內存,還能執行BGSAVE命令嗎?
這里有一個概念叫寫時復制(copy on write),在使用系統調用fork創建子進程之后,主進程與子進程的內存數據暫時還是共享的,但是當主進程需要修改內存數據時,系統會自動將該內存塊復制一份,以此實現內存數據的隔離。
命令BGSAVE的執行流程如下圖所示:

Redis的進程模型/線程模型還是比較復雜的,這里也只是簡單介紹了部分場景下的多線程以及多進程,其他場景下的多線程、多進程還有待讀者自己研究。
作者介紹
李樂:好未來Golang開發專家、西安電子科技大學碩士,曾就職于滴滴,樂于鉆研技術與源碼,合著有《高效使用Redis:一書學透數據存儲與高可用集群》《Redis5設計與源碼分析》《Nginx底層設計與源碼分析》。
▼
延伸閱讀

《高效使用Redis:一書學透數據存儲與高可用集群》
推薦語:深入Redis數據結構與底層實現,攻克Redis數據存儲與集群管理難題。
本文來源:原創,圖片來源:pexels
責任編輯:王瑩,部門領導:寧姍
發布人:白鈺?