redis 單線程的理解


單線程模型

  Redis客戶端對服務端的每次調用都經歷了發送命令,執行命令,返回結果三個過程。其中執行命令階段,由於Redis是單線程來處理命令的,所有每一條到達服務端的命令不會立刻執行,所有的命令都會進入一個隊列中,然后逐個被執行。並且多個客戶端發送的命令的執行順序是不確定的。但是可以確定的是不會有兩條命令被同時執行,不會產生並發問題,這就是Redis的單線程基本模型。

 

1. redis單線程問題

  單線程指的是網絡請求模塊使用了一個線程(所以不需考慮並發安全性),即一個線程處理所有網絡請求,其他模塊仍用了多個線程。

2. 為什么說redis能夠快速執行

(1) 絕大部分請求是純粹的內存操作(非常快速)

(2) 采用單線程,避免了不必要的上下文切換和競爭條件

(3) 非阻塞IO - IO多路復用,Redis采用epoll做為I/O多路復用技術的實現,再加上Redis自身的事件處理模型將epoll中的連接,讀寫,關閉都轉換為了時間,不在I/O上浪費過多的時間。

  Redis采用單線程模型,每條命令執行如果占用大量時間,會造成其他線程阻塞,對於Redis這種高性能服務是致命的,所以Redis是面向高速執行的數據庫。

3. redis的內部實現

  內部實現采用epoll,采用了epoll+自己實現的簡單的事件框架。epoll中的讀、寫、關閉、連接都轉化成了事件,然后利用epoll的多路復用特性,絕不在io上浪費一點時間 這3個條件不是相互獨立的,特別是第一條,如果請求都是耗時的,采用單線程吞吐量及性能可想而知了。應該說redis為特殊的場景選擇了合適的技術方案。

4. Redis關於線程安全問題

   redis實際上是采用了線程封閉的觀念,把任務封閉在一個線程,自然避免了線程安全問題,不過對於需要依賴多個redis操作的復合操作來說,依然需要鎖,而且有可能是分布式鎖。

 

另一篇對redis單線程的理解:Redis單線程理解

個人理解

        redis分客戶端和服務端,一次完整的redis請求事件有多個階段(客戶端到服務器的網絡連接-->redis讀寫事件發生-->redis服務端的數據處理(單線程)-->數據返回)。平時所說的redis單線程模型,本質上指的是服務端的數據處理階段,不牽扯網絡連接和數據返回,這是理解redis單線程的第一步。接下來,針對不同階段分別闡述個人的一些理解。

1:客戶端到服務器的網絡連接

首先,客戶端和服務器是socket通信方式,socket服務端監聽可同時接受多個客戶端請求,這點很重要,如果不理解可先記住。注意這里可以理解為本質上與redis無關,這里僅僅做網絡連接,或者可以理解為,為redis服務端提供網絡交互api。

        假設建立網絡連接需要30秒(為了更容易理解,所以時間上擴大了N倍)

2:redis讀寫事件發生並向服務端發送請求數據

        首先確定一點,redis的客戶端與服務器端通信是基於TCP連接(不懂去看,基礎很重要),第一階段僅僅是建立了客戶端到服務器的網絡連接,然后才是發生第二階段的讀寫事件。

        完成了上一個階段的網絡連接,redis客戶端開始真正向服務器發起讀寫事件,假設是set(寫)事件,此時redis客戶端開始向建立的網絡流中送數據,服務端可以理解為給每一個網絡連接創建一個線程同時接收客戶端的請求數據。

        假設從客戶端發數據,到服務端接收完數據需要10秒。

3:redis服務端的數據處理

        服務端完成了第二階段的數據接收,接下來開始依據接收到的數據做邏輯處理,然后得到處理后的數據。數據處理可以理解為一次方法調用,帶參調用方法,最終得到方法返回值。不要想復雜,重在理解流程。

        假設redis服務端處理數據需要0.1秒

4:數據返回

        這一階段很簡單,當reids服務端數據處理完后 就會立即返回處理后的數據,沒什么特別需要強調的。

        假設服務端把處理后的數據回送給客戶端需要5秒。

那么什么是Reids的單線程

        第一階段說過,redis是以socket方式通信,socket服務端可同時接受多個客戶端請求連接,也就是說,redis服務同時面對多個redis客戶端連接請求,而redis服務本身是單線程運行。

        假設,現在有A,B,C,D,E五個客戶端同時發起redis請求,A優先稍微一點點第一個到達,然后是B,C,D,E依次到達,此時redis服務端開始處理A請求,建立連接需要30秒,獲取請求數據需要10秒,然后處理數據需要0.1秒,回送數據給客戶端需要5秒,總共大概需要45秒。也就是說,下一個B請求需要等待45秒,這里注意,也許這五個幾乎同時請求,由於socket可以同時處理多個請求,所以建立網絡連接階段時間差可忽略,但是在第二階段,服務端需要什么事都不干,坐等10秒中,對於CPU和客戶端來說是無法忍受的。所以說單線程效率非常,非常低,但是正是因為這些類似問題,Redis單線程本質上並不是如此運行。接下來討論redis真正的單線程運行方式。

        客戶端與服務端建立連接交由socket,可以同時建立多個連接(這里應該是多線程/多進程),建立的連接redis是知道的(為什么知道,去看socket編程,再次強調基礎很重要),然后redis會基於這些建立的連接去探測哪個連接已經接收完了客戶端的請求數據(注意:不是探測哪個連接建立好了,而是探測哪個接收完了請求數據),而且這里的探測動作就是單線程的開始,一旦探測到則基於接收到的數據開始數據處理階段,然后返回數據,再繼續探測下一個已經接收完請求數據的網絡連接。注意,從探測到數據處理再到數據返回,全程單線程。這應該就是所謂的redis單線程。至於內部有多復雜我們無需關心,我們追求的是理解流程,苛求原理,但不能把內臟都挖出來。

        從探測到接受完請求數據的網絡連接到最終的數據返回,服務器只需要5.1秒,這個時間是我放大N倍后的數據,實際時間遠遠小於這個,可能是5.1的N萬分之一時間,為什么這么說,因為數據的處理是在本地內存中,速度有多快任你想象,最終的返回數據雖然牽扯到網絡,但是網絡連接已經建立,這個速度也是非常非常快的,只是比數據處理階段慢那么一點點。因此單線程方式在效率上其實並不需要擔心。

 

IO多路復用

  參考:https://www.zhihu.com/question/32163005

 要弄清問題先要知道問題的出現原因

原因:

  由於進程的執行過程是線性的(也就是順序執行),當我們調用低速系統I/O(read,write,accept等等),進程可能阻塞,此時進程就阻塞在這個調用上,不能執行其他操作.阻塞很正常.

  接下來考慮這么一個問題:一個服務器進程和一個客戶端進程通信,服務器端read(sockfd1,bud,bufsize),此時客戶端進程沒有發送數據,那么read(阻塞調用)將阻塞,直到客戶端調用write(sockfd,but,size)發來數據.在一個客戶和服務器通信時這沒什么問題;

  當多個客戶與服務器通信時當多個客戶與服務器通信時,若服務器阻塞於其中一個客戶sockfd1,當另一個客戶的數據到達套接字sockfd2時,服務器不能處理,仍然阻塞在read(sockfd1,...)上;此時問題就出現了,不能及時處理另一個客戶的服務,咋么辦?

  I/O多路復用來解決!

I/O多路復用:

  繼續上面的問題,有多個客戶連接,sockfd1,sockfd2,sockfd3..sockfdn同時監聽這n個客戶,當其中有一個發來消息時就從select的阻塞中返回,然后就調用read讀取收到消息的sockfd,然后又循環回select阻塞;這樣就不會因為阻塞在其中一個上而不能處理另一個客戶的消息

  “I/O多路復用”的英文是“I/O multiplexing”,可以百度一下multiplexing,就能得到這個圖:

Q:

  那這樣子,在讀取socket1的數據時,如果其它socket有數據來,那么也要等到socket1讀取完了才能繼續讀取其它socket的數據吧。那不是也阻塞住了嗎?而且讀取到的數據也要開啟線程處理吧,那這和多線程IO有什么區別呢?

A:

  1.CPU本來就是線性的不論什么都需要順序處理並行只能是多核CPU

  2.io多路復用本來就是用來解決對多個I/O監聽時,一個I/O阻塞影響其他I/O的問題,跟多線程沒關系.

  3.跟多線程相比較,線程切換需要切換到內核進行線程切換,需要消耗時間和資源.而I/O多路復用不需要切換線/進程,效率相對較高,特別是對高並發的應用nginx就是用I/O多路復用,故而性能極佳.但多線程編程邏輯和處理上比I/O多路復用簡單.而I/O多路復用處理起來較為復雜.

-----------------------------------------------------------------------------------------------------------------------------------

  I/O 指的是網絡I/O。
  多路指的是多個TCP 連接(Socket 或Channel)。
  復用指的是復用一個或多個線程。


  它的基本原理就是不再由應用程序自己監視連接,而是由內核替應用程序監視文件描述符。

  客戶端在操作的時候,會產生具有不同事件類型的socket。在服務端,I/O 多路復用程序(I/O Multiplexing Module)會把消息放入隊列中,然后通過文件事件分派器(Fileevent Dispatcher),轉發到不同的事件處理器中。

  多路復用有很多的實現,以select 為例,當用戶進程調用了多路復用器,進程會被阻塞。內核會監視多路復用器負責的所有socket,當任何一個socket 的數據准備好了,多路復用器就會返回。這時候用戶進程再調用read 操作,把數據從內核緩沖區拷貝到用戶空間。

  I/O 多路復用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒(readable)狀態,select()函數就可以返回。

 

Redis 單線程 是否就代表線程安全?
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

class Demo extends Thread
{
    public void run()
    {
        Jedis jedis1 = new Jedis();
        for (int i=0;i<100;i++){
            int num = Integer.parseInt(jedis1.get("num"));// 1: 代碼行1
            num = num + 1; // 2: 代碼行2
            jedis1.set("num",num+"");
            System.out.println(jedis1.get("num"));
        }
    }
}

public class test{

    public static void main(String... args){
        Jedis jedis = new Jedis();
        jedis.set("num","1");
        new Demo().start();
        new Demo().start();
    }
}

  如代碼所示,例如當線程1在代碼行讀取數值為99時候,此時線程2頁執行讀取操作也是99,隨后同時執行num=num+1,之后更新,導致一次更新丟失,這就是這個代碼測試的錯誤之處。所以Redis本身是線程安全的,但是你還需要保證你的業務必須也是線程安全的

 

注意:千萬不要以為原子操作是線程安全的,原子操作只能保證命令全執行或者全不執行,並不會保證線程安全操作。例如數據庫中的事務就是原子的,依舊還需要提供並發控制!!!!

原子性操作是否線程安全?

  原文:https://stackoverflow.com/questions/14370575/why-are-atomic-operations-considered-thread-safe

 

  1. 原子操作是針對訪問共享變量的操作而言的。涉及局部變量訪問的操作無所謂是否原子的。
  2. 原子操作是從該操作的執行線程以外的線程來描述的,也就是說它只有在多線程環境下才有意義。


原子操作得“不可分割”包括兩層含義
  1.訪問(讀、寫)某個共享變量的操作從其執行線程以外的任何線程來看,該操作要么已經執行結束要么尚未發生,即其他線程不會“看到”該操作執行了部分的中間效果。

  2.訪問同一組共享變量的原子操作是不能夠被交錯的。

 

此原子性與數據庫原子性有區別:最主要區別是數據庫的原子性,可以被其他線程看見中間狀態,否則就不會有隔離級別的事了。



另一篇關於redis I/O多路復用的介紹

redis I/O多路復用機制

為什么redis要使用I/O多路復用技術?

       Redis 是跑在單線程中的,所有的操作都是按照順序線性執行的,但是由於讀寫操作等待用戶輸入或輸出都是阻塞的,所以 I/O 操作在一般情況下往往不能直接返回,這會導致某一文件的 I/O 阻塞導致整個進程無法對其它客戶提供服務,而 I/O多路復用就是為了解決這個問題而出現的。

  多路I/O復用模型是利用 selectpollepoll 可以同時監察多個流的 I/O 事件的能力,在空閑的時候,會把當前線程阻塞掉。當有一個或多個流有 I/O事件時,就從阻塞態中喚醒,於是程序就會輪詢一遍所有的流(epoll 是只輪詢那些真正發出了事件的流),並且只依次順序的處理就緒的流,這種做法就避免了大量的無用操作。

這里“多路”指的是多個網絡連接,“復用”指的是復用同一個線程。

  采用多路 I/O 復用技術可以讓單個線程高效的處理多個連接請求(盡量減少網絡 IO 的時間消耗),且 Redis在內存中操作數據的速度非常快,也就是說內存內的操作不會成為影響Redis性能的瓶頸,主要由以下幾點造就了 Redis 具有很高的吞吐量。

 (1) 網絡IO都是通過Socket實現,Server在某一個端口持續監聽,客戶端通過Socket(IP+Port)與服務器建立連接(ServerSocket.accept),成功建立連接之后,就可以使用Socket中封裝的InputStream和OutputStream進行IO交互了。針對每個客戶端,Server都會創建一個新線程專門用於處理。

 (2) 默認情況下,網絡IO是阻塞模式,即服務器線程在數據到來之前處於【阻塞】狀態,等到數據到達,會自動喚醒服務器線程,着手進行處理。阻塞模式下,一個線程只能處理一個流的IO事件。

 (3) 為了提升服務器線程處理效率,有以下三種思路

      a.非阻塞[忙輪詢]:采用死循環方式輪詢每一個流,如果有IO事件就處理,這樣一個線程可以處理多個流,但效率不高,容易導致CPU空轉。

      b.Select代理(無差別輪詢):可以觀察多個流的IO事件,如果所有流都沒有IO事件,則將線程進入阻塞狀態,如果有一個或多個發生了IO事件,則喚醒線程去處理。但是會遍歷所有的流,找出流需要處理的流。如果流個數為N,則時間復雜度為O(N)

      c.Epoll代理:Select代理有一個缺點,線程在被喚醒后輪詢所有的Stream,會存在無效操作。Epoll哪個流發生了I/O事件會通知處理線程,對流的操作都是有意義的,復雜度降低到了O(1)。

舉個栗子:

  每個快遞員------------------>每個線程

  每個快遞-------------------->每個socket(I/O流)

  快遞的物流位置-------------->socket的不同狀態

  客戶(寄/收)快遞請求-------------->來自客戶端的請求

      快遞公司的經營方式-------------->服務端運行的代碼

       一輛車---------------------->CPU的核數

1.經營方式一就是傳統的並發模型,每個I/O流(快遞)都有一個新的線程(快遞員)管理。

2.經營方式二就是I/O多路復用。只有單個線程(一個快遞員),通過跟蹤每個I/O流(快遞)的狀態(每個快遞的送達地點),來管理多個I/O流。

      redis線程模型:

epoll IO多路復用模型實現機制

  epoll沒有這個限制,它所支持的FD上限是最大可以打開文件的數目,這個數字一般遠大於2048,一般來說這個數目和系統內存關系很大,具體數目可以 cat /proc/sys/fs/file-max查看,在1GB內存的機器上大約是10萬左右

  場景:有100萬個客戶端同時與一個服務器進程保持着TCP連接。而每一時刻,通常只有幾百上千個TCP連接是活躍的。

  在select/poll時代,服務器進程每次都把這100萬個連接告訴操作系統(從用戶態復制句柄數據結構到內核態),讓操作系統內核去查詢這些套接字上是否有事件發生,輪詢完后,再將句柄數據復制到用戶態,讓服務器應用程序輪詢處理已發生的網絡事件,這一過程資源消耗較大,因此,select/poll一般只能處理幾千的並發連接。

  如果沒有I/O事件產生,我們的程序就會阻塞在select處。有個問題,我們從select僅知道了有I/O事件發生了,但卻不知是哪幾個流,只能無差別輪詢所有流,找出能讀或寫數據的流進行操作。

  使用select,O(n)的無差別輪詢復雜度,同時處理的流越多,每一次無差別輪詢時間就越長。

  epoll的設計和實現與select完全不同。epoll通過在Linux內核中申請一個簡易的文件系統(文件系統一般用B+樹數據結構實現)。把原先的select/poll調用分成了3個部分:

  1)調用epoll_create()建立一個epoll對象(在epoll文件系統中為這個句柄對象分配資源)

  2)調用epoll_ctl向epoll對象中添加這100萬個連接的套接字

  3)調用epoll_wait收集發生的事件的連接實現上面場景只需要在進程啟動時建立一個epoll對象,在需要的時候向epoll對象中添加或者刪除連接。同時epoll_wait的效率也非常高,因為調用epoll_wait時,並沒有一股腦的向操作系統復制這100萬個連接的句柄數據,內核也不需要去遍歷全部的連接。

底層實現:
當某一進程調用epoll_create方法時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。eventpoll結構體如下所示: 

  每一個epoll對象都有一個獨立的eventpoll結構體,用於存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,通過紅黑樹可以高效的識別重復事件(紅黑樹的插入時間效率是lg(n),其中n為樹的高度)。

  所有添加到epoll中的事件都會與設備(網卡)驅動程序建立回調關系,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。

在epoll中,對於每一個事件,都會建立一個epitem結構體,如下所示:

  當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不為空,則把發生的事件復制到用戶態,同時將事件數量返回給用戶。

優勢:

  1. 不用重復傳遞。我們調用epoll_wait時就相當於以往調用select/poll,但是這時卻不用傳遞socket句柄給內核,因為內核已經在epoll_ctl中拿到了要監控的句柄列表。

  2. 在內核里,一切皆文件。epoll向內核注冊了一個文件系統,用於存儲上述的被監控socket。當你調用epoll_create時,就會在這個虛擬的epoll文件系統里創建一個file結點。這個file不是普通文件,它只服務於epoll。epoll在被內核初始化時(操作系統啟動),同時會開辟出epoll自己的內核高速cache區,用於安置每一個我們想監控的socket,這些socket會以紅黑樹的形式保存在內核cache里,以支持快速的查找、插入、刪除。這個內核高速cache區,就是建立連續的物理內存頁,然后在之上建立slab層,簡單的說,就是物理上分配好你想要的size的內存對象,每次使用時都是使用空閑的已分配好的對象。 

  3. 極其高效的原因:我們在調用epoll_create時,內核除了幫我們在epoll文件系統里建了個file結點,在內核cache里建了個紅黑樹用於存儲以后epoll_ctl傳來的socket外,還會再建立一個list鏈表,用於存儲准備就緒的事件,當epoll_wait調用時,僅僅觀察這個list鏈表里有沒有數據即可。有數據就返回,沒有數據就sleep,等到timeout時間到后即使鏈表沒數據也返回。   

 

  這個准備就緒list鏈表是怎么維護的呢?當我們執行epoll_ctl時,除了把socket放到epoll文件系統里file對象對應的紅黑樹上之外,還會給內核中斷處理程序注冊一個回調函數,告訴內核,如果這個句柄的中斷到了,就把它放到准備就緒list鏈表里。所以,當一個socket上有數據到了,內核在把網卡上的數據copy到內核中后就來把socket插入到准備就緒鏈表里了。epoll的基礎就是回調呀!      

  一顆紅黑樹,一張准備就緒句柄鏈表,少量的內核cache,就幫我們解決了大並發下的socket處理問題。執行epoll_create時,創建了紅黑樹和就緒鏈表,執行epoll_ctl時,如果增加socket句柄,則檢查在紅黑樹中是否存在,存在立即返回,不存在則添加到樹干上,然后向內核注冊回調函數,用於當中斷事件來臨時向准備就緒鏈表中插入數據。執行epoll_wait時立刻返回准備就緒鏈表里的數據即可。     

  epoll獨有的兩種模式LT和ET。無論是LT和ET模式,都適用於以上所說的流程。區別是,LT模式下,只要一個句柄上的事件一次沒有處理完,會在以后調用epoll_wait時次次返回這個句柄,而ET模式僅在第一次返回。   

  LT和ET都是電子里面的術語,ET是邊緣觸發,LT是水平觸發,一個表示只有在變化的邊際觸發,一個表示在某個階段都會觸發。

  LT, ET這件事怎么做到的呢?當一個socket句柄上有事件時,內核會把該句柄插入上面所說的准備就緒list鏈表,這時我們調用epoll_wait,會把准備就緒的socket拷貝到用戶態內存,然后清空准備就緒list鏈表,最后,epoll_wait干了件事,就是檢查這些socket,如果不是ET模式(就是LT模式的句柄了),並且這些socket上確實有未處理的事件時,又把該句柄放回到剛剛清空的准備就緒鏈表了。所以,非ET的句柄,只要它上面還有事件,epoll_wait每次都會返回這個句柄。

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM