• <ins id="pjuwb"></ins>
    <blockquote id="pjuwb"><pre id="pjuwb"></pre></blockquote>
    <noscript id="pjuwb"></noscript>
          <sup id="pjuwb"><pre id="pjuwb"></pre></sup>
            <dd id="pjuwb"></dd>
            <abbr id="pjuwb"></abbr>

            loop_in_codes

            低調做技術__歡迎移步我的獨立博客 codemaro.com 微博 kevinlynx

            #

            淺析glibc中thread tls的一處bug

            最早的時候是在程序初始化過程中開啟了一個timer(timer_create),這個timer第一次觸發的時間較短時就會引起程序core掉,core的位置也是不定的。使用valgrind可以發現有錯誤的內存寫入:

            ==31676== Invalid write of size 8
            ==31676==    at 0x37A540F852: _dl_allocate_tls_init (in /lib64/ld-2.5.so)
            ==31676==    by 0x4E26BD3: pthread_create@@GLIBC_2.2.5 (in /lib64/libpthread-2.5.so)
            ==31676==    by 0x76E0B00: timer_helper_thread (in /lib64/librt-2.5.so)
            ==31676==    by 0x4E2673C: start_thread (in /lib64/libpthread-2.5.so)
            ==31676==    by 0x58974BC: clone (in /lib64/libc-2.5.so)
            ==31676==  Address 0xf84dbd0 is 0 bytes after a block of size 336 alloc'd
            ==31676==    at 0x4A05430: calloc (vg_replace_malloc.c:418)
            ==31676==    by 0x37A5410082: _dl_allocate_tls (in /lib64/ld-2.5.so)
            ==31676==    by 0x4E26EB8: pthread_create@@GLIBC_2.2.5 (in /lib64/libpthread-2.5.so)
            ==31676==    by 0x76E0B00: timer_helper_thread (in /lib64/librt-2.5.so)
            ==31676==    by 0x4E2673C: start_thread (in /lib64/libpthread-2.5.so)
            ==31676==    by 0x58974BC: clone (in /lib64/libc-2.5.so)
            

            google _dl_allocate_tls_init 相關發現一個glibc的bug Bug 13862 和我的情況有點類似。本文就此bug及tls相關實現做一定闡述。

            需要查看glibc的源碼,如何確認使用的glibc的版本,可以這樣:

            $ /lib/libc.so.6
            GNU C Library stable release version 2.5, by Roland McGrath et al.
            ...
            

            為了方便,還可以直接在(glibc Cross Reference)[http://osxr.org/glibc/source/?v=glibc-2.17]網頁上進行查看,版本不同,但影響不大。

            BUG描述

            要重現13862 BUG作者提到要滿足以下條件:

            The use of a relatively large number of dynamic libraries, loaded at runtime using dlopen.

            The use of thread-local-storage within those libraries.

            A thread exiting prior to the number of loaded libraries increasing a significant amount, followed by a new thread being created after the number of libraries has increased.

            簡單來說,就是在加載一大堆包含TLS變量的動態庫的過程中,開啟了一個線程,這個線程退出后又開啟了另一個線程。

            這和我們的問題場景很相似。不同的是我們使用的是timer,但timer在觸發時也是開啟新的線程,并且這個線程會立刻退出:

            /nptl/sysdeps/unix/sysv/linux/timer_routines.c

            timer_helper_thread(...)  // 用于檢測定時器觸發的輔助線程
            {
                ...
                  pthread_t th;
                  (void) pthread_create (&th, &tk->attr, timer_sigev_thread, // 開啟一個新線程調用用戶注冊的定時器函數
                             td);
                ...
            }

            要重現此BUG可以使用我的實驗代碼 thread-tls,或者使用Bug 13862 中的附件

            TLS相關實現

            可以順著_dl_allocate_tls_init函數的實現查看相關聯的部分代碼。該函數遍歷所有加載的包含TLS變量的模塊,初始化一個線程的TLS數據結構。

            每一個線程都有自己的堆棧空間,其中單獨存儲了各個模塊的TLS變量,從而實現TLS變量在每一個線程中都有單獨的拷貝。TLS與線程的關聯關系可以查看下圖:

            應用層使用的pthread_t實際是個pthread對象的地址。創建線程時線程的堆棧空間和pthread結構是一塊連續的內存。但這個地址并不指向這塊內存的首地址。相關代碼:/nptl/allocatestack.c allocate_stack,該函數分配線程的堆棧內存。

            pthread第一個成員是tcbhead_ttcbhead_tdtv指向了一個dtv_t數組,該數組的大小隨著當前程序載入的模塊多少而動態變化。每一個模塊被載入時,都有一個l_tls_modid,其直接作為dtv_t數組的下標索引。tcbhead_t中的dtv實際指向的是dtv_t第二個元素,第一個元素用于記錄整個dtv_t數組有多少元素,第二個元素也做特殊使用,從第三個元素開始,才是用于存儲TLS變量。

            一個dtv_t存儲的是一個模塊中所有TLS變量的地址,當然這些TLS變量都會被放在連續的內存空間里。dtv_t::pointer::val正是用于指向這塊內存的指針。對于非動態加載的模塊它指向的是線程堆棧的位置;否則指向動態分配的內存位置。

            以上結構用代碼描述為,

            union dtv_t {
                size_t counter;
                struct {
                    void *val; /* point to tls variable memory */
                    bool is_static;
                } pointer;
            };
             
            struct tcbhead_t {
                void *tcb;
                dtv_t *dtv; /* point to a dtv_t array */
                void *padding[22]; /* other members i don't care */
            };
            
            struct pthread {
                tcbhead_t tcb;
                /* more members i don't care */
            };

            dtv是一個用于以模塊為單位存儲TLS變量的數組

            實際代碼參看 /nptl/descr.h 及 nptl/sysdeps/x86_64/tls.h。

            實驗

            使用g++ -o thread -g -Wall -lpthread -ldl thread.cpp編譯代碼,即在創建線程前加載了一個.so:

            Breakpoint 1, dump_pthread (id=1084229952) at thread.cpp:40
            40          printf("pthread %p, dtv %p\n", pd, dtv);
            (gdb) set $dtv=pd->tcb.dtv
            (gdb) p $dtv[-1]
            $1 = {counter = 17, pointer = {val = 0x11, is_static = false}}
            (gdb) p $dtv[3]
            $2 = {counter = 18446744073709551615, pointer = {val = 0xffffffffffffffff, is_static = false}}
            

            dtv[3]對應著動態加載的模塊,is_static=falseval被初始化為-1:

            /elf/dl-tls.c _dl_allocate_tls_init

            if (map->l_tls_offset == NO_TLS_OFFSET
               || map->l_tls_offset == FORCED_DYNAMIC_TLS_OFFSET)
             {
               /* For dynamically loaded modules we simply store
                  the value indicating deferred allocation.  */
               dtv[map->l_tls_modid].pointer.val = TLS_DTV_UNALLOCATED;
               dtv[map->l_tls_modid].pointer.is_static = false;
               continue;
             }

            dtv數組大小之所以為17,可以參看代碼 /elf/dl-tls.c allocate_dtv

            // dl_tls_max_dtv_idx 隨著載入模塊的增加而增加,載入1個.so則是1 
            
            dtv_length = GL(dl_tls_max_dtv_idx) + DTV_SURPLUS; // DTV_SURPLUS 14
            dtv = calloc (dtv_length + 2, sizeof (dtv_t));
            if (dtv != NULL)
             {
               /* This is the initial length of the dtv.  */
               dtv[0].counter = dtv_length;

            繼續上面的實驗,當調用到.so中的function時,其TLS被初始化,此時dtv[3]val指向初始化后的TLS變量地址:

            68          fn();
            (gdb)
            0x601808, 0x601804, 0x601800
            72          return 0;
            (gdb) p $dtv[3]
            $3 = {counter = 6297600, pointer = {val = 0x601800, is_static = false}}
            (gdb) x/3xw 0x601800
            0x601800:       0x55667788      0xaabbccdd      0x11223344
            

            這個時候還可以看看dtv[1]中的內容,正是指向了pthread前面的內存位置:

            (gdb) p $dtv[1]
            $5 = {counter = 1084229936, pointer = {val = 0x40a00930, is_static = true}}
            (gdb) p/x tid
            $7 = 0x40a00940
            

            結論:

            • 線程中TLS變量的存儲是以模塊為單位的

            so模塊加載

            這里也并不太需要查看dlopen等具體實現,由于使用__thread來定義TLS變量,整個實現涉及到ELF加載器的一些細節,深入下去內容較多。這里直接通過實驗的手段來了解一些實現即可。

            上文已經看到,在創建線程前如果動態加載了.so,dtv數組的大小是會隨之增加的。如果是在線程創建后再載入.so呢?

            使用g++ -o thread -g -Wall -lpthread -ldl thread.cpp -DTEST_DTV_EXPAND -DSO_CNT=1編譯程序,調試得到:

            73          load_sos();
            (gdb)
            0x601e78, 0x601e74, 0x601e70
            
            Breakpoint 1, dump_pthread (id=1084229952) at thread.cpp:44
            44          printf("pthread %p, dtv %p\n", pd, dtv);
            (gdb) p $dtv[-1]
            $3 = {counter = 17, pointer = {val = 0x11, is_static = false}}
            (gdb) p $dtv[4]
            $4 = {counter = 6299248, pointer = {val = 0x601e70, is_static = false}}
            

            在新載入了.so時,dtv數組大小并沒有新增,dtv[4]直接被拿來使用。

            因為dtv初始大小為16,那么當載入的.so超過這個數字的時候會怎樣?

            使用g++ -o thread -g -Wall -lpthread -ldl thread.cpp -DTEST_DTV_EXPAND編譯程序:

            ...
            pthread 0x40a00940, dtv 0x6016a0
            ...
            Breakpoint 1, dump_pthread (id=1084229952) at thread.cpp:44
            44          printf("pthread %p, dtv %p\n", pd, dtv);
            (gdb) p dtv
            $2 = (dtv_t *) 0x6078a0
            (gdb) p dtv[-1]
            $3 = {counter = 32, pointer = {val = 0x20, is_static = false}}
            (gdb) p dtv[5]
            $4 = {counter = 6300896, pointer = {val = 0x6024e0, is_static = false}}
            

            可以看出,dtv被重新分配了內存(0x6016a0 -> 0x6078a0)并做了擴大。

            以上得出結論:

            • 創建線程前dtv的大小會根據載入模塊數量決定
            • 創建線程后新載入的模塊會動態擴展dtv的大小(必要的時候)

            pthread堆棧重用

            allocate_stack中分配線程堆棧時,有一個從緩存中取的操作:

            allocate_stack(..) {
                ...
                pd = get_cached_stack (&size, &mem);
                ...
            }
            /* Get a stack frame from the cache.  We have to match by size since
               some blocks might be too small or far too large.  */
            get_cached_stack(...) {
                ...
                list_for_each (entry, &stack_cache) // 根據size從stack_cache中取
                { ... }
                ...
                /* Clear the DTV.  */
                dtv_t *dtv = GET_DTV (TLS_TPADJ (result));
                for (size_t cnt = 0; cnt < dtv[-1].counter; ++cnt)
                    if (! dtv[1 + cnt].pointer.is_static
                            && dtv[1 + cnt].pointer.val != TLS_DTV_UNALLOCATED)
                        free (dtv[1 + cnt].pointer.val);
                memset (dtv, '\0', (dtv[-1].counter + 1) * sizeof (dtv_t));
            
                /* Re-initialize the TLS.  */
                _dl_allocate_tls_init (TLS_TPADJ (result));
            }

            get_cached_stack會把取出的pthread中的dtv重新初始化。注意 _dl_allocate_tls_init 中是根據模塊列表來初始化dtv數組的。

            實驗

            當一個線程退出后,它就可能被當做cache被get_cached_stack取出復用。

            使用g++ -o thread -g -Wall -lpthread -ldl thread.cpp -DTEST_CACHE_STACK編譯程序,運行:

            $ ./thread
            ..
            pthread 0x413c9940, dtv 0x1be46a0
            ... 
            pthread 0x413c9940, dtv 0x1be46a0
            

            回顧BUG

            當新創建的線程復用了之前退出的線程堆棧時,由于在_dl_allocate_tls_init中初始化dtv數組時是根據當前載入的模塊數量而定。如果在這個時候模塊數已經超過了這個復用的dtv數組大小,那么就會出現寫入非法的內存。使用valgrind檢測就會得到本文開頭提到的結果。

            由于dtv數組大小通常會稍微大點,所以在新加載的模塊數量不夠多時程序還不會有問題。可以通過控制測試程序中SO_CNT的大小看看dtv中內容的變化。

            另外,我查看了下glibc的更新歷史,到目前為止(2.20)這個BUG還沒有修復。

            參考文檔

            posted @ 2014-10-07 21:38 Kevin Lynx 閱讀(3613) | 評論 (1)編輯 收藏

            zookeeper節點數與watch的性能測試

            zookeeper中節點數量理論上僅受限于內存,但一個節點下的子節點數量受限于request/response 1M數據 (size of data / number of znodes)

            zookeeper的watch機制用于數據變更時zookeeper的主動通知。watch可以被附加到每一個節點上,那么如果一個應用有10W個節點,那zookeeper中就可能有10W個watch(甚至更多)。每一次在zookeeper完成改寫節點的操作時就會檢測是否有對應的watch,有的話則會通知到watch。Zookeeper-Watcher機制與異步調用原理

            本文將關注以下內容:

            • zookeeper的性能是否會受節點數量的影響
            • zookeeper的性能是否會受watch數量的影響

            測試方法

            在3臺機器上分別部署一個zookeeper,版本為3.4.3,機器配置:

            Intel(R) Xeon(R) CPU E5-2430 0 @ 2.20GHz
            
            16G
            
            java version "1.6.0_32"
            Java(TM) SE Runtime Environment (build 1.6.0_32-b05)
            OpenJDK (Taobao) 64-Bit Server VM (build 20.0-b12-internal, mixed mode)
            

            大部分實驗JVM堆大小使用默認,也就是1/4 RAM

            java -XX:+PrintFlagsFinal -version | grep HeapSize
            

            測試客戶端使用zk-smoketest,針對watch的測試則是我自己寫的。基于zk-smoketest我寫了些腳本可以自動跑數據并提取結果,相關腳本可以在這里找到:https://github.com/kevinlynx/zk-benchmark

            測試結果

            節點數對讀寫性能的影響

            測試最大10W個節點,度量1秒內操作數(ops):

            可見節點數的增加并不會對zookeeper讀寫性能造成影響。

            節點數據大小對讀寫性能的影響

            這個網上其實已經有公認的結論。本身單個節點數據越大,對網絡方面的吞吐就會造成影響,所以其數據越大讀寫性能越低也在預料之中。

            寫數據會在zookeeper集群內進行同步,所以其速度整體會比讀數據更慢。該實驗需要把超時時間進行一定上調,同時我也把JVM最大堆大小調整到8G。

            測試結果很明顯,節點數據大小會嚴重影響zookeeper效率。

            watch對讀寫性能的影響

            zk-smoketest自帶的latency測試有個參數--watch_multiple用來指定watch的數量,但其實僅是指定客戶端的數量,在server端通過echo whcp | nc 127.0.0.1 4181會發現實際每個節點還是只有一個watch。

            在我寫的測試中,則是通過創建多個客戶端來模擬單個節點上的多個watch。這也更符合實際應用。同時對節點的寫也是在另一個獨立的客戶端中,這樣可以避免zookeeper client的實現對測試帶來的干擾。

            每一次完整的測試,首先是對每個節點添加節點數據的watch,然后在另一個客戶端中對這些節點進行數據改寫,收集這些改寫操作的耗時,以確定添加的watch對這些寫操作帶來了多大的影響。

            圖中,0 watch表示沒有對節點添加watch;1 watch表示有一個客戶端對每個節點進行了watch;3 watch表示有其他3個客戶端對每個節點進行了watch;依次類推。

            可見,watch對寫操作還是有較大影響的,畢竟需要進行網絡傳輸。同樣,這里也顯示出整個zookeeper的watch數量同節點數量一樣對整體性能沒有影響。

            總體結論

            • 對單個節點的操作并不會因為zookeeper中節點的總數而受到影響
            • 數據大小對zookeeper的性能有較大影響,性能和內存都會
            • 單個節點上獨立session的watch數對性能有一定影響

            posted @ 2014-09-21 20:56 Kevin Lynx 閱讀(4922) | 評論 (0)編輯 收藏

            淺析靜態庫鏈接原理

            靜態庫的鏈接基本上同鏈接目標文件.obj/.o相同,但也有些不同的地方。本文簡要描述linux下靜態庫在鏈接過程中的一些細節。

            靜態庫文件格式

            靜態庫遠遠不同于動態庫,不涉及到符號重定位之類的問題。靜態庫本質上只是將一堆目標文件進行打包而已。靜態庫沒有標準,不同的linux下都會有些細微的差別。大致的格式wiki上描述的較清楚:

            Global header
            -----------------        +-------------------------------
            File header 1       ---> | File name
            File content 1  |        | File modification timestamp 
            -----------------        | Owner ID
            File header 2            | Group ID
            File content 2           | File mode
            -----------------        | File size in bytes
            ...                      | File magic
                                     +-------------------------------
            

            File header很多字段都是以ASCII碼表示,所以可以用文本編輯器打開。

            靜態庫本質上就是使用ar命令打包一堆.o文件。我們甚至可以用ar隨意打包一些文件:

            $ echo 'hello' > a.txt && echo 'world' > b.txt
            $ ar -r test.a a.txt b.txt
            $ cat test.a
            !<arch>
            a.txt/          1410628755  60833 100   100644  6         `
            hello
            b.txt/          1410628755  60833 100   100644  6         `
            world
            

            鏈接過程

            鏈接器在鏈接靜態庫時,同鏈接一般的.o基本相似。鏈接過程大致可以歸納下圖:

            總結為:

            • 所有傳入鏈接器的.o都會被鏈接進最終的可執行程序;鏈接.o時,會將.o中的global symbolunresolved symbol放入一個臨時表
            • 如果多個.o定義了相同的global symbol,那么就會得到多重定義的鏈接錯誤
            • 如果鏈接結束了,unresolved symbol表不為空,那么就會得到符號未定義的鏈接錯誤
            • .a靜態庫處理本質上就是處理其中的每一個.o,不同的是,如果某個.o中沒有一個符號屬于unresolved symbol表,也就是鏈接器此時懷疑該.o沒有必要,那么其就會被忽略

            可以通過一些代碼來展示以上過程。在開發C++程序時,可以利用文件靜態變量會先于main之前執行做一些可能利于程序結構的事情。如果某個.o(包含靜態庫中打包的.o)被鏈接進程序,那么其文件靜態變量就會先于main初始化。

            // test.cpp
            #include <stdio.h>
            
            class Test {
            public:
                Test() {
                    printf("Test ctor\n");
                }
            };
            
            static Test s_test;
            
            // lib.cpp
            #include <stdio.h>
            
            class Lib {
            public:
                Lib() {
                    printf("Lib ctor\n");
                }
            };
            
            static Lib s_lib;
            
            // main.cpp
            #include <stdio.h>
            
            int main() {
                printf("main\n");
                return 0;
            }

            以上代碼main.cpp中未引用任何test.cpp``lib.cpp中的符號:

            $ g++ -o test test.o lib.o main.o
            $ ./test
            Lib ctor
            Test ctor
            main
            

            生成的可執行程序執行如預期,其鏈接了test.o``lib.o。但是如果把lib.o以靜態庫的形式進行鏈接,情況就不一樣了:為了做對比,基于以上的代碼再加一個文件,及修改main.cpp

            // libfn.cpp
            int sum(int a, int b) {
                return a + b;
            }
            
            // main.cpp
            #include <stdio.h>
            
            int main() {
                printf("main\n");
                extern int sum(int, int);
                printf("sum: %d\n", sum(2, 3));
                return 0;
            }

            libfn.olib.o創建為靜態庫:

            $ ar -r libfn.a libfn.o lib.o
            $ g++ -o test main.o test.o -lfn -L.
            $ ./test
            Test ctor
            main
            sum: 5
            

            因為lib.o沒有被鏈接,導致其文件靜態變量也未得到初始化。

            調整鏈接順序,可以進一步檢驗前面的鏈接過程:

            # 將libfn.a的鏈接放在main.o前面
            
            $ g++ -o test test.o -lfn main.o  -L.
            main.o: In function `main':
            main.cpp:(.text+0x19): undefined reference to `sum(int, int)'
            collect2: ld returned 1 exit status
            

            這個問題遇到得比較多,也有點讓人覺得莫名其妙。其原因就在于鏈接器在鏈接libfn.a的時候,發現libfn.o依然沒有被之前鏈接的*.o引用到,也就是沒有任何符號在unresolved symbol table,所以libfn.o也被忽略。

            一些實踐

            在實際開發中還會遇到一些靜態庫相關的問題。

            鏈接順序問題

            前面的例子已經展示了這個問題。調整庫的鏈接順序可以解決大部分問題,但當靜態庫之間存在環形依賴時,則無法通過調整順序來解決。

            -whole-archive

            -whole-archive選項告訴鏈接器把靜態庫中的所有.o都進行鏈接,針對以上例子:

            $ g++ -o test -L. test.o -Wl,--whole-archive -lfn main.o -Wl,--no-whole-archive
            $ ./test
            Lib ctor
            Test ctor
            main
            sum: 5
            

            lib.o也被鏈接了進來。-Wl選項告訴gcc將其作為鏈接器參數傳入;之所以在命令行結尾加上--no-whole-archive是為了告訴編譯器不要鏈接gcc默認的庫

            可以看出這個方法還是有點暴力了。

            –start-group

            格式為:

            --start-group archives --end-group
            

            位于--start-group --end-group中的所有靜態庫將被反復搜索,而不是默認的只搜索一次,直到不再有新的unresolved symbol產生為止。也就是說,出現在這里的.o如果發現有unresolved symbol,則可能回到之前的靜態庫中繼續搜索。

            $ g++ -o test -L. test.o -Wl,--start-group -lfn main.o -Wl,--end-group
            $ ./test
            Test ctor
            main
            sum: 5
            

            查看ldd關于該參數的man page還可以一窺鏈接過程的細節:

            The specified archives are searched repeatedly until no new undefined references are created. Normally, an archive is searched only once in the order that it is specified on the command line. If a symbol in that archive is needed to resolve an undefined symbol referred to by an object in an archive that appears later on the command line, the linker would not be able to resolve that reference. By grouping the archives, they all be searched repeatedly until all possible references are resolved.

            嵌套靜態庫

            由于ar創建靜態庫時本質上只是對文件進行打包,所以甚至可以創建一個嵌套的靜態庫,從而測試鏈接器是否會遞歸處理靜態庫中的.o

            $ ar -r libfn.a libfn.o
            $ ar -r liboutfn.a libfn.a lib.o
            $ g++ -o test -L. test.o main.o -loutfn
            main.o: In function `main':
            main.cpp:(.text+0x19): undefined reference to `sum(int, int)'
            collect2: ld returned 1 exit status
            

            可見鏈接器并不會遞歸處理靜態庫中的文件

            之所以要提到嵌套靜態庫這個問題,是因為我發現很多時候我們喜歡為一個靜態庫工程鏈接其他靜態庫。當然,這里的鏈接并非真正的鏈接(僅是打包),這個過程當然可以聰明到將其他靜態庫里的.o提取出來然后打包到新的靜態庫。

            如果我們使用的是類似scons這種封裝更高的依賴項管理工具,那么它是否會這樣干呢?

            基于之前的例子,我們使用scons來創建liboutfn.a

            # Sconstruct
            StaticLibrary('liboutfn.a', ['libfn.a', 'lib.o'])
            

            使用文本編輯器打開liboutfn.a就可以看到其內容,或者使用:

            $ ar -tv liboutfn.a
            rw-r--r-- 60833/100   1474 Sep 14 02:59 2014 libfn.a
            rw-r--r-- 60833/100   2448 Sep 14 02:16 2014 lib.o
            

            可見scons也只是單純地打包。所以,在scons中構建一個靜態庫時,再鏈接其他靜態庫是沒有意義的

            參考文檔

            posted @ 2014-09-15 22:47 Kevin Lynx 閱讀(4245) | 評論 (2)編輯 收藏

            理解git常用命令原理

            git不同于類似SVN這種版本管理系統,雖然熟悉常用的操作就可以滿足大部分需求,但為了在遇到麻煩時不至于靠蠻力去嘗試,了解git的原理還是很有必要。

            文件

            通過git管理的文件版本信息全部存放在根目錄.git下,稍微看下:

            $ ls .git
            COMMIT_EDITMSG HEAD branches description index logs packed-refs
            FETCH_HEAD ORIG_HEAD config hooks info objects refs
            

            git除了提供給我們平時常用的一些命令之外,還有很多底層命令,可以用于查看以上部分文件表示的東西。

            三個區域/三類對象

            理解git里的三個區域概念非常重要。git里很多常用的命令都是圍繞著這三個區域來做的。它們分別為:

            • working directory,也就是你所操作的那些文件
            • history,你所提交的所有記錄,文件歷史內容等等。git是個分布式版本管理系統,在你本地有項目的所有歷史提交記錄;文件歷史記錄;提交日志等等。
            • stage(index),暫存區域,本質上是個文件,也就是.git/index

            git中還有三類常用對象(實際不止三種),理解這三類對象也很重要。分別為:

            • blob,用于表示一個文件
            • tree,用于表示一個目錄,索引到若干文件或子目錄
            • commit,用于表示一次提交(commit)

            所有對象都會以文件的形式保存在.git/objects目錄,一個對象一個文件。

            接下來把上面所有的內容關聯起來。做以下操作:

            $ mkdir test && cd test
            $ git init
            $ ls -a .git/objects # 沒有文件
            . .. info pack
            $ touch readme # working directory里增加了一個readme文件
            $ git add readme # 添加一個文件到stage區域
            $ git ls-files --stage # 這個命令可以查看stage區域里的內容,可以看到有readme
            100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 readme
            $ ls -a .git/objects # 同時.git/objects增加了一個e6的目錄
            . .. e6 info pack
            $ ls -a .git/objects/e6/ # e6目錄下增加了一個文件
            . .. 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
            

            上面的操作展示了git中三個區域三個對象的部分關聯關系。git中每個對象都以一個40個字符長度的SHA-1哈希值為標識,以這40個字符的前2個字符作為文件夾,以后38個字符為文件名。

            基于以上繼續操作:

            $ git commit -m 'first commit' # commit會將stage里標識的文件提交到history區域
            [master (root-commit) 8bf6969] first commit
            0 files changed, 0 insertions(+), 0 deletions(-)
            create mode 100644 readme
            $ ls -a .git/objects # 增加了2個文件,也就是2個對象
            . .. 8b e6 e8 info pack
            $ git ls-files --stage # stage僅表示當前被版本管理的文件,所以內容不變
            100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 readme
            # git cat-file 命令可以用于查看.git/objects下的文件,意即可用于查看對象
            $ git cat-file -t e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 # 這個是之前git add readme產生的文件對象 blob
            blob
            # 同樣我們來查看git commit -m后新增的兩個對象
            $ ls .git/objects/8b/
            f696927c17526eb8f0c6dae8badb968a001ed0
            $ git cat-file -t 8bf696927c17526eb8f0c6dae8badb968a001ed0 # 記得帶上8b這個文件夾名,才算一個完整的對象ID。這是一個commit對象
            commit
            $ ls .git/objects/e8
            0ad49ace82167de62e498622d70377d913c79e
            $ git cat-file -t e80ad49ace82167de62e498622d70377d913c79e # tree對象
            tree
            

            區域和對象如何交互的可以用下圖描述:

            通過git cat-file -p可以查看對象的更多描述,git cat-file -t僅獲取對象的類型。做以下操作獲得更深的認識:

            # 這個commit對象記錄了提交者的信息,還包括指向的tree對象
            $ git cat-file -p 8bf696927c17526eb8f0c6dae8badb968a001ed0
            tree e80ad49ace82167de62e498622d70377d913c79e
            author Kevin Lynx <kevinlynx@gmail.com> 1410090424 +0800
            committer Kevin Lynx <kevinlynx@gmail.com> 1410090424 +0800
            first commit
            # 查看tree對象可以看出tree指向的blob對象
            $ git cat-file -p e80ad49ace82167de62e498622d70377d913c79e
            100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 readme
            

            即使是已經被版本管理的文件,發生改動后(正常改動或合并)都使用git add來重新mark它。創建第二次提交進一步認識:

            $ echo 'hello git' > readme
            $ touch install
            $ git ls-files --stage # 不使用git add,暫存區域內容沒變
            100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 readme
            # 此時stage里內容未變,提示no changes added to commit
            $ git commit
            # On branch master
            # Changed but not updated:
            # (use "git add <file>..." to update what will be committed)
            # (use "git checkout -- <file>..." to discard changes in working directory)
            #
            # modified: readme
            #
            # Untracked files:
            # (use "git add <file>..." to include in what will be committed)
            #
            # install
            no changes added to commit (use "git add" and/or "git commit -a")
            $ git add readme
            $ ls .git/objects/ # git add之后.git/objects下新增文件
            8b 8d e6 e8 info pack
            $ ls .git/objects/8d/
            0e41234f24b6da002d962a26c2495ea16a425f
            $ git cat-file -p 8d0e41234f24b6da002d962a26c2495ea16a425f # 查看該新增對象
            hello git
            # 這個時候還可以在提交前撤銷git add readme
            $ git reset readme # 從history到stage
            Unstaged changes after reset:
            M readme
            $ cat readme
            hello git
            $ git checkout readme # 從stage到working directory
            $ cat readme # 沒有內容,回到第一個版本
            $ git add install # 添加新創建的文件
            $ git ls-files --stage # stage中的內容是最新的readme和新添加的install
            100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 install
            100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0 readme
            $ ls .git/objects/
            8b 8d e6 e8 info pack
            

            以上,發現一個有趣的現象:新加的install文件的SHA-1哈希值和之前的readme相同,這是因為這2個文件都是空的,內容相同。繼續:

            $ git commit -m 'second commit'
            $ ls .git/objects/ # 提交后新增2個對象
            45 72 8b 8d e6 e8 info pack
            $ ls .git/objects/72/
            b94e949c5fca6092cc74c751a7bb35ee71c283
            $ git cat-file -p 72b94e949c5fca6092cc74c751a7bb35ee71c283
            tree 45cf0bd049d7eea4558b14f33a894db27c7c1130 # 新創建的tree對象
            parent 8bf696927c17526eb8f0c6dae8badb968a001ed0 # commit對象有parent,正是上一次提交
            author Kevin Lynx <kevinlynx@gmail.com> 1410094456 +0800
            committer Kevin Lynx <kevinlynx@gmail.com> 1410094456 +0800
            second commit
            # 新創建的tree對象指向了2個文件
            $ git cat-file -p 45cf0bd049d7eea4558b14f33a894db27c7c1130
            100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 install
            100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f readme
            

            需要注意,有時候我們使用git commit -a,它會直接將已經加入版本管理的文件一起提交,從而跳過了git add這個過程。同git很多操作一樣,它只是一個快捷操作。

            總結

            從上面的內容其實已經可以看出git的優勢所在,它可以完全不需要服務器就完成一個版本控制系統的所有事情。在.git文件中它記錄了所有的文件的所有歷史提交,記錄了每一次提交的信息。

            git的常用操作中還會涉及到分支、遠端倉庫等,空了再寫。

            參考文檔

            posted @ 2014-09-09 21:35 Kevin Lynx 閱讀(4502) | 評論 (0)編輯 收藏

            C/C++中手動獲取調用堆棧

            當我們的程序core掉之后,如果能獲取到core時的函數調用堆棧將非常有利于定位問題。在Windows下可以使用SEH機制;在Linux下通過gdb使用coredump文件即可。

            但有時候由于某些錯誤導致堆棧被破壞,發生拿不到調用堆棧的情況。

            一些基礎預備知識本文不再詳述,可以參考以下文章:

            需要知道的信息:

            • 函數調用對應的call指令本質上是先壓入下一條指令的地址到堆棧,然后跳轉到目標函數地址
            • 函數返回指令ret則是從堆棧取出一個地址,然后跳轉到該地址
            • EBP寄存器始終指向當前執行函數相關信息(局部變量)所在棧中的位置,ESP則始終指向棧頂
            • 每一個函數入口都會保存調用者的EBP值,在出口處都會重設EBP值,從而實現函數調用的現場保存及現場恢復
            • 64位機器增加了不少寄存器,從而使得函數調用的參數大部分時候可以通過寄存器傳遞;同時寄存器名字發生改變,例如EBP變為RBP

            在函數調用中堆棧的情況可用下圖說明:

            將代碼對應起來:

                void g() {
                    int *p = 0;
                    long a = 0x1234;
                    printf("%p %x\n", &a, a);
                    printf("%p %x\n", &p, p);
                    f();
                    *p = 1;
                }
                void b(int argc, char **argv) {
                    printf("%p %p\n", &argc, &argv);
                    g();
                }
                int main(int argc, char **argv) {
                    b(argc, argv);
                    return 0;
                }

            在函數g()中斷點,看看堆棧中的內容(64位機器):

            (gdb) p $rbp
            $2 = (void *) 0x7fffffffe370
            (gdb) p &p
            $3 = (int **) 0x7fffffffe368
            (gdb) p $rsp
            $4 = (void *) 0x7fffffffe360
            (gdb) x/8ag $rbp-16
            0x7fffffffe360: 0x1234  0x0
            0x7fffffffe370: 0x7fffffffe390  0x400631 <b(int, char**)+43>
            0x7fffffffe380: 0x7fffffffe498  0x1a561cbc0
            0x7fffffffe390: 0x7fffffffe3b0  0x40064f <main(int, char**)+27>
            

            對應的堆棧圖:

            可以看看例子中0x400631 <b(int, char**)+43>0x40064f <main(int, char**)+27>中的代碼:

            (gdb) disassemble 0x400631
            ...
            0x0000000000400627 <b(int, char**)+33>: callq  0x400468 <printf@plt>
            0x000000000040062c <b(int, char**)+38>: callq  0x4005ae <g()>
            0x0000000000400631 <b(int, char**)+43>: leaveq                           # call的下一條指令
            ...
            (gdb) disassemble 0x40064f
            ... 
            0x000000000040063f <main(int, char**)+11>:      mov    %rsi,-0x10(%rbp)
            0x0000000000400643 <main(int, char**)+15>:      mov    -0x10(%rbp),%rsi
            0x0000000000400647 <main(int, char**)+19>:      mov    -0x4(%rbp),%edi
            0x000000000040064a <main(int, char**)+22>:      callq  0x400606 <b(int, char**)>
            0x000000000040064f <main(int, char**)+27>:      mov    $0x0,%eax         # call的下一條指令
            ...
            

            順帶一提,每個函數入口和出口,對應的設置RBP代碼為:

            (gdb) disassemble g
            ...
            0x00000000004005ae <g()+0>:     push   %rbp               # 保存調用者的RBP到堆棧
            0x00000000004005af <g()+1>:     mov    %rsp,%rbp          # 設置自己的RBP
            ...
            0x0000000000400603 <g()+85>:    leaveq                    # 等同于:movq %rbp, %rsp
                                                                      #         popq %rbp
            0x0000000000400604 <g()+86>:    retq                      
            

            由以上可見,通過當前的RSP或RBP就可以找到調用堆棧中所有函數的RBP;找到了RBP就可以找到函數地址。因為,任何時候的RBP指向的堆棧位置就是上一個函數的RBP;而任何時候RBP所在堆棧中的前一個位置就是函數返回地址。

            由此我們可以自己構建一個導致gdb無法取得調用堆棧的例子:

                void f() {
                    long *p = 0;
                    p = (long*) (&p + 1); // 取得g()的RBP
                    *p = 0;  // 破壞g()的RBP
                }
                void g() {
                    int *p = 0;
                    long a = 0x1234;
                    printf("%p %x\n", &a, a);
                    printf("%p %x\n", &p, p);
                    f();
                    *p = 1; // 寫0地址導致一次core
                }
                void b(int argc, char **argv) {
                    printf("%p %p\n", &argc, &argv);
                    g();
                }
                int main(int argc, char **argv) {
                    b(argc, argv);
                    return 0;
                }

            使用gdb運行該程序:

            Program received signal SIGSEGV, Segmentation fault.
            g () at ebp.c:37
            37          *p = 1;
            (gdb) bt
            Cannot access memory at address 0x8
            (gdb) p $rbp
            $1 = (void *) 0x0
            

            bt無法獲取堆棧,在函數g()中RBP被改寫為0,gdb從0偏移一個地址長度即0x8,嘗試從0x8內存位置獲取函數地址,然后提示Cannot access memory at address 0x8

            RBP出現了問題,我們就可以通過RSP來手動獲取調用堆棧。因為RSP是不會被破壞的,要通過RSP獲取調用堆棧則需要偏移一些局部變量所占的空間:

            (gdb) p $rsp
            $2 = (void *) 0x7fffffffe360
            (gdb) x/8ag $rsp+16             # g()中局部變量占16字節
            0x7fffffffe370: 0x7fffffffe390  0x400631 <b(int, char**)+43>
            0x7fffffffe380: 0x7fffffffe498  0x1a561cbc0
            0x7fffffffe390: 0x7fffffffe3b0  0x40064f <main(int, char**)+27>
            0x7fffffffe3a0: 0x7fffffffe498  0x100000000
            

            基于以上就可以手工找到調用堆棧:

            g()
            0x400631 <b(int, char**)+43>
            0x40064f <main(int, char**)+27>
            

            上面的例子本質上也是破壞堆棧,并且僅僅破壞了保存了的RBP。在實際情況中,堆棧可能會被破壞得更多,則可能導致手動定位也較困難。

            堆棧被破壞還可能導致更多的問題,例如覆蓋了函數返回地址,則會導致RIP錯誤;例如堆棧的不平衡。導致堆棧被破壞的原因也有很多,例如局部數組越界;delete/free棧上對象等

            omit-frame-pointer

            使用RBP獲取調用堆棧相對比較容易。但現在編譯器都可以設置不使用RBP(gcc使用-fomit-frame-pointer,msvc使用/Oy),對于函數而言不設置其RBP意味著可以節省若干條指令。在函數內部則完全使用RSP的偏移來定位局部變量,包括嵌套作用域里的局部變量,即使程序實際運行時不會進入這個作用域。

            例如:

                void f2() {
                    int a = 0x1234;
                    if (a > 0) {
                        int b = 0xff;
                        b = a;
                    }
                }

            gcc中使用-fomit-frame-pointer生成的代碼為:

            (gdb) disassemble f2
            Dump of assembler code for function f2:
            0x00000000004004a5 <f2+0>:      movl   $0x1234,-0x8(%rsp)    # int a = 0x1234
            0x00000000004004ad <f2+8>:      cmpl   $0x0,-0x8(%rsp)       
            0x00000000004004b2 <f2+13>:     jle    0x4004c4 <f2+31>      
            0x00000000004004b4 <f2+15>:     movl   $0xff,-0x4(%rsp)      # int b = 0xff
            0x00000000004004bc <f2+23>:     mov    -0x8(%rsp),%eax
            0x00000000004004c0 <f2+27>:     mov    %eax,-0x4(%rsp)
            0x00000000004004c4 <f2+31>:     retq
            

            可以發現f2()沒有操作RBP之類的指令了。

            posted @ 2014-09-02 22:14 Kevin Lynx 閱讀(5943) | 評論 (3)編輯 收藏

            基于protobuf的RPC實現

            可以對照使用google protobuf RPC實現echo service一文看,細節本文不再描述。

            google protobuf只負責消息的打包和解包,并不包含RPC的實現,但其包含了RPC的定義。假設有下面的RPC定義:

            service MyService {
                    rpc Echo(EchoReqMsg) returns(EchoRespMsg) 
                }

            那么要實現這個RPC需要最少做哪些事?總結起來需要完成以下幾步:

            客戶端

            RPC客戶端需要實現google::protobuf::RpcChannel。主要實現RpcChannel::CallMethod接口。客戶端調用任何一個RPC接口,最終都是調用到CallMethod。這個函數的典型實現就是將RPC調用參數序列化,然后投遞給網絡模塊進行發送。

            void CallMethod(const ::google::protobuf::MethodDescriptor* method,
                              ::google::protobuf::RpcController* controller,
                              const ::google::protobuf::Message* request,
                              ::google::protobuf::Message* response,
                              ::google::protobuf::Closure* done) {
                    ...
                    DataBufferOutputStream outputStream(...) // 取決于你使用的網絡實現
                    request->SerializeToZeroCopyStream(&outputStream);
                    _connection->postData(outputStream.getData(), ...
                    ...
                }

            服務端

            服務端首先需要實現RPC接口,直接實現MyService中定義的接口:

            class MyServiceImpl : public MyService {
                    virtual void Echo(::google::protobuf::RpcController* controller,
                        const EchoReqMsg* request,
                        EchoRespMsg* response,
                        ::google::protobuf::Closure* done) {
                        ...
                        done->Run();
                    }
                }

            標示service&method

            基于以上,可以看出服務端根本不知道客戶端想要調用哪一個RPC接口。從服務器接收到網絡消息,到調用到MyServiceImpl::Echo還有很大一段距離。

            解決方法就是在網絡消息中帶上RPC接口標識。這個標識可以直接帶上service name和method name,但這種實現導致網絡消息太大。另一種實現是基于service name和method name生成一個哈希值,因為接口不會太多,所以較容易找到基本不沖突的字符串哈希算法。

            無論哪種方法,服務器是肯定需要建立RPC接口標識到protobuf service對象的映射的。

            這里提供第三種方法:基于option的方法。

            protobuf中option機制類似于這樣一種機制:service&method被視為一個對象,其有很多屬性,屬性包含內置的,以及用戶擴展的。用戶擴展的就是option。每一個屬性有一個值。protobuf提供訪問service&method這些屬性的接口。

            首先擴展service&method的屬性,以下定義這些屬性的key:

            extend google.protobuf.ServiceOptions {
                  required uint32 global_service_id = 1000; 
                }
                extend google.protobuf.MethodOptions {
                  required uint32 local_method_id = 1000;
                }

            應用層定義service&method時可以指定以上key的值:

            service MyService
                {
                    option (arpc.global_service_id) = 2302; 
            
                    rpc Echo(EchoReqMsg) returns(EchoRespMsg) 
                    {
                        option (arpc.local_method_id) = 1;
                    }
                    rpc Echo_2(EchoReqMsg) returns(EchoRespMsg) 
                    {
                        option (arpc.local_method_id) = 2;
                    }
                    ...
                }

            以上相當于在整個應用中,每個service都被賦予了唯一的id,單個service中的method也有唯一的id。

            然后可以通過protobuf取出以上屬性值:

            void CallMethod(const ::google::protobuf::MethodDescriptor* method,
                              ::google::protobuf::RpcController* controller,
                              const ::google::protobuf::Message* request,
                              ::google::protobuf::Message* response,
                              ::google::protobuf::Closure* done) {
                    ...
                    google::protobuf::ServiceDescriptor *service = method->service();
                    uint32_t serviceId = (uint32_t)(service->options().GetExtension(global_service_id));
                    uint32_t methodId = (uint32_t)(method->options().GetExtension(local_method_id));
                    ...
                }

            考慮到serviceId methodId的范圍,可以直接打包到一個32位整數里:

            uint32_t ret = (serviceId << 16) | methodId;
            

            然后就可以把這個值作為網絡消息頭的一部分發送。

            當然服務器端是需要建立這個標識值到service的映射的:

            bool MyRPCServer::registerService(google::protobuf::Service *rpcService) {
                    const google::protobuf::ServiceDescriptor = rpcService->GetDescriptor();
                    int methodCnt = pSerDes->method_count();
            
                    for (int i = 0; i < methodCnt; i++) {
                        google::protobuf::MethodDescriptor *pMethodDes = pSerDes->method(i);
                        uint32_t rpcCode = PacketCodeBuilder()(pMethodDes); // 計算出映射值
                        _rpcCallMap[rpcCode] = make_pair(rpcService, pMethodDes); // 建立映射
                    }
                    return true;
                }

            服務端收到RPC調用后,取出這個標識值,然后再從_rpcCallMap中取出對應的service和method,最后進行調用:

            google::protobuf::Message* response = _pService->GetResponsePrototype(_pMethodDes).New();
                // 用于回應的closure
                RPCServerClosure *pClosure = new (nothrow) RPCServerClosure( 
                        _channelId, _pConnection, _pReqMsg, pResMsg, _messageCodec, _version);
                RPCController *pController = pClosure->GetRpcController();
                ...
                // protobuf 生成的CallMethod,會自動調用到Echo接口
                _pService->CallMethod(_pMethodDes, pController, _pReqMsg, pResMsg, pClosure);

            參考

            posted @ 2014-08-31 19:40 Kevin Lynx 閱讀(6670) | 評論 (0)編輯 收藏

            分布式環境中的負載均衡策略

            在分布式系統中相同的服務常常會部署很多臺,每一臺被稱為一個服務節點(實例)。通過一些負載均衡策略將服務請求均勻地分布到各個節點,以實現整個系統支撐海量請求的需求。本文描述一些簡單的負載均衡策略。

            Round-robin

            簡單地輪詢。記錄一個選擇位置,每次請求來時調整該位置到下一個節點:

            curId = ++curId % nodeCnt
            

            隨機選擇

            隨機地在所有節點中選擇:

            id = random(nodeCnt);
            

            本機優先

            訪問后臺服務的訪問者可能本身是一個整合服務,或者是一個proxy,如果后臺服務節點恰好有節點部署在本機的,則可以優先使用。在未找到本機節點時則可以繼續走Round-robin策略:

            if (node->ip() == local_ip) {
                return node;
            } else {
                return roundRobin();
            }
            

            一旦遍歷到本機節點,則后面的請求會一直落到本機節點。所以這里可以加上一些權重機制,僅是保證本機節點會被優先選擇,但不會被一直選擇。例如:

            // initial
            cur_weight = 100;
            ...
            // select node
            cur_weight -= 5;
            if (cur_weight <= 0)
                cur_weight = 100;
            if (cur_weight > 50 && node->ip() == local_ip) {
                return node;
            } else {
                return roundRobin();
            }
            

            本機房優先

            服務節點可能會被部署到多個機房,有時候確實是需要考慮跨機房服務。同本機優先策略類似,本機房優先則是優先考慮位于相同機房內的服務節點。該請求是從哪個機房中的前端服務發送過來的,則需要前端在請求參數中攜帶上機房ID。

            在服務節點對應的數據結構中,也最好按照機房來組織。

            本機房優先策略實際上會作為節點選擇的第一道工序,它可以把非本機房的節點先過濾掉,然后再傳入后面的各種節點選擇策略。這里還可以考慮節點數參數,如果本機房的節點過少,則可以不使用該策略,避免流量嚴重不均。

            Weighted Round-Robin

            加權輪詢。相對于普通輪詢而言,該策略中每一個節點都有自己的權重,優先選擇權重更大的節點。權重可以根據機器性能預先配置。摘抄一下網上的算法:

            假設有一組服務器S = {S0, S1, …, Sn-1},W(Si)表示服務器Si的權值,一個
            指示變量i表示上一次選擇的服務器,指示變量cw表示當前調度的權值,max(S)
            表示集合S中所有服務器的最大權值,gcd(S)表示集合S中所有服務器權值的最大
            公約數。變量i初始化為-1,cw初始化為零。
            
            while (true) {
              i = (i + 1) mod n;
              if (i == 0) {
                 cw = cw - gcd(S); 
                 if (cw <= 0) {
                   cw = max(S);
                   if (cw == 0)
                     return NULL;
                 }
              } 
              if (W(Si) >= cw) 
                return Si;
            }
            

            遍歷完所有節點后權重衰減,衰減到0后重新開始。這樣可以讓權重更大的節點被選擇得更多。

            Consistent Hash

            一致性哈希。一致性哈希用于在分布式環境中,分布在各個節點上的請求,不會因為新增節點(擴容)或減少節點(節點宕機)而變化。如果每個服務節點上都有自己的緩存,其保存了該節點響應請求時的回應。正常情況下,這些緩存都可以很好地被運用,也即cache命中率較高。

            如果某個節點不可用了,我們的選擇策略又是基于所有節點的公平選擇,那么原來一直分配在節點A上請求就很可能被分配到節點B上,從而導致節點A上的緩存較難被命中。這個時候就可以運用一致性哈希來解決。

            其基本思想是,在節點選擇區間內,在找節點時以順時針方向找到不小于該請求對應的哈希值的節點。在這個區間里增加很多虛擬節點,每一個虛擬節點相當于一個物理節點的引用,這樣相當于把物理節點變成了一個哈希值區間。這個哈希值區間不會因為增加節點和減少節點而變化,那么對某個請求而言,它就會始終落到這個區間里,也就會始終被分配到原來的節點。

            至于這個不可用的節點,其上的請求也會被均勻地分配到其他節點中。

            摘抄網上的一段代碼:

            // 添加一個物理節點時,會隨之增加很多虛擬節點
            template <class Node, class Data, class Hash>
            size_t HashRing<Node, Data, Hash>::AddNode(const Node& node)
            {
                size_t hash;
                std::string nodestr = Stringify(node);
                for (unsigned int r = 0; r < replicas_; r++) {
                    hash = hash_((nodestr + Stringify(r)).c_str());
                    ring_[hash] = node;  // 物理節點和虛擬節點都保存在一個std::map中
                }
                return hash;
            }
            
            // 選擇data對應的節點,data可以是請求
            template <class Node, class Data, class Hash>
            const Node& HashRing<Node, Data, Hash>::GetNode(const Data& data) const
            {
                if (ring_.empty()) {
                    throw EmptyRingException();
                }
                size_t hash = hash_(Stringify(data).c_str()); // 對請求進行哈希
                typename NodeMap::const_iterator it;
                // Look for the first node >= hash
                it = ring_.lower_bound(hash); // 找到第一個不小于請求哈希的節點
                if (it == ring_.end()) {
                    // Wrapped around; get the first node
                    it = ring_.begin();
                }
                return it->second;
            }
            

            參考一致性 hash 算法(consistent hashing)Consistent Hash Ring

            posted @ 2014-08-26 00:11 Kevin Lynx 閱讀(4129) | 評論 (0)編輯 收藏

            select真的有限制嗎

            在剛開始學習網絡編程時,似乎莫名其妙地就會被某人/某資料告訴select函數是有fd(file descriptor)數量限制的。在最近的一次記憶里還有個人笑說select只支持64個fd。我甚至還寫過一篇不負責任甚至錯誤的博客(突破select的FD_SETSIZE限制)。有人說,直接重新定義FD_SETSIZE就可以突破這個select的限制,也有人說除了重定義這個宏之外還的重新編譯內核。

            事實具體是怎樣的?實際上,造成這些混亂的原因恰好是不同平臺對select的實現不一樣。

            Windows的實現

            MSDN.aspx)上對select的說明:

            int select(
              _In_     int nfds,
              _Inout_  fd_set *readfds,
              _Inout_  fd_set *writefds,
              _Inout_  fd_set *exceptfds,
              _In_     const struct timeval *timeout
            );
            
            nfds [in] Ignored. The nfds parameter is included only for compatibility with Berkeley sockets.
            

            第一個參數MSDN只說沒有使用,其存在僅僅是為了保持與Berkeley Socket的兼容。

            The variable FD_SETSIZE determines the maximum number of descriptors in a set. (The default value of FD_SETSIZE is 64, which can be modified by defining FD_SETSIZE to another value before including Winsock2.h.) Internally, socket handles in an fd_set structure are not represented as bit flags as in Berkeley Unix.

            Windows上select的實現不同于Berkeley Unix,后者使用位標志來表示socket

            在MSDN的評論中有人提到:

            Unlike the Linux versions of these macros which use a single calculation to set/check the fd, the Winsock versions use a loop which goes through the entire set of fds each time you call FD_SET or FD_ISSET (check out winsock2.h and you’ll see). So you might want to consider an alternative if you have thousands of sockets!

            不同于Linux下處理fd_set的那些宏(FD_CLR/FD_SET之類),Windows上這些宏的實現都使用了一個循環,看看這些宏的大致實現(Winsock2.h):

            #define FD_SET(fd, set) do { \
                u_int __i; \
                for (__i = 0; __i < ((fd_set FAR *)(set))->fd_count; __i++) { \
                    if (((fd_set FAR *)(set))->fd_array[__i] == (fd)) { \
                        break; \
                    } \
                } \
                if (__i == ((fd_set FAR *)(set))->fd_count) { \
                    if (((fd_set FAR *)(set))->fd_count < FD_SETSIZE) { \
                        ((fd_set FAR *)(set))->fd_array[__i] = (fd); \
                        ((fd_set FAR *)(set))->fd_count++; \
                    } \
                } \
            } while(0)
            

            看下Winsock2.h中關于fd_set的定義:

            typedef struct fd_set {
                u_int fd_count;
                SOCKET fd_array[FD_SETSIZE];
            } fd_set;
            

            再看一篇更重要的MSDN Maximum Number of Sockets Supported.aspx):

            The Microsoft Winsock provider limits the maximum number of sockets supported only by available memory on the local computer. The maximum number of sockets that a Windows Sockets application can use is not affected by the manifest constant FD_SETSIZE. If an application is designed to be capable of working with more than 64 sockets using the select and WSAPoll functions, the implementor should define the manifest FD_SETSIZE in every source file before including the Winsock2.h header file.

            Windows上select支持的socket數量并不受宏FD_SETSIZE的影響,而僅僅受內存的影響。如果應用程序想使用超過FD_SETSIZE的socket,僅需要重新定義FD_SETSIZE即可。

            實際上稍微想想就可以明白,既然fd_set里面已經有一個socket的數量計數,那么select的實現完全可以使用這個計數,而不是FD_SETSIZE這個宏。那么結論是,select至少在Windows上并沒有socket支持數量的限制。當然效率問題這里不談。

            這看起來推翻了我們一直以來沒有深究的一個事實。

            Linux的實現

            在上面提到的MSDN中,其實已經提到了Windows與Berkeley Unix實現的不同。在select的API文檔中也看到了第一個參數并沒有說明其作用。看下Linux的man

            nfds is the highest-numbered file descriptor in any of the three sets, plus 1.

            第一個參數簡單來說就是最大描述符+1。

            An fd_set is a fixed size buffer. Executing FD_CLR() or FD_SET() with a value of fd that is negative or is equal to or larger than FD_SETSIZE will result in undefined behavior.

            明確說了,如果調用FD_SET之類的宏fd超過了FD_SETSIZE將導致undefined behavior。也有人專門做了測試:select system call limitation in Linux。也有現實遇到的問題:socket file descriptor (1063) is larger than FD_SETSIZE (1024), you probably need to rebuild Apache with a larger FD_SETSIZE

            看起來在Linux上使用select確實有FD_SETSIZE的限制。有必要看下相關的實現 fd_set.h

            typedef __uint32_t      __fd_mask;
            
            /* 32 = 2 ^ 5 */
            #define __NFDBITS       (32)
            #define __NFDSHIFT      (5)
            #define __NFDMASK       (__NFDBITS - 1)
            
            /*
             * Select uses bit fields of file descriptors.  These macros manipulate
             * such bit fields.  Note: FD_SETSIZE may be defined by the user.
             */
            
            #ifndef FD_SETSIZE
            #define FD_SETSIZE      256
            #endif
            
            #define __NFD_SIZE      (((FD_SETSIZE) + (__NFDBITS - 1)) / __NFDBITS)
            
            typedef struct fd_set {
                __fd_mask       fds_bits[__NFD_SIZE];
            } fd_set;
            

            在這份實現中不同于Windows實現,它使用了位來表示fd。看下FD_SET系列宏的大致實現:

            #define FD_SET(n, p)    \
               ((p)->fds_bits[(unsigned)(n) >> __NFDSHIFT] |= (1 << ((n) & __NFDMASK)))
            

            添加一個fd到fd_set中也不是Windows的遍歷,而是直接位運算。這里也有人對另一份類似實現做了剖析:linux的I/O多路轉接select的fd_set數據結構和相應FD_宏的實現分析。在APUE中也提到fd_set

            這種數據類型(fd_set)為每一可能的描述符保持了一位。

            既然fd_set中不包含其保存了多少個fd的計數,那么select的實現里要知道自己要處理多少個fd,那只能使用FD_SETSIZE宏去做判定,但Linux的實現選用了更好的方式,即通過第一個參數讓應用層告訴select需要處理的最大fd(這里不是數量)。那么其實現大概為:

            for (int i = 0; i < nfds; ++i) {
                if (FD_ISSET...
                   ...
            }
            

            如此看來,Linux的select實現則是受限于FD_SETSIZE的大小。這里也看到,fd_set使用位數組來保存fd,那么fd本身作為一個int數,其值就不能超過FD_SETSIZE這不僅僅是數量的限制,還是其取值的限制。實際上,Linux上fd的取值是保證了小于FD_SETSIZE的(但不是不變的)Is the value of a Linux file descriptor always smaller than the open file limits?

            Each process is further limited via the setrlimit(2) RLIMIT_NOFILE per-process limit on the number of open files. 1024 is a common RLIMIT_NOFILE limit. (It’s very easy to change this limit via /etc/security/limits.conf.)

            fd的取值會小于RLIMIT_NOFILE,有很多方法可以改變這個值。這個值默認情況下和FD_SETSIZE應該是一樣的。這個信息告訴我們,Linux下fd的取值應該是從0開始遞增的(理論上,實際上還有stdin/stdout/stderr之類的fd)。這才能保證select的那些宏可以工作。

            應用層使用

            標準的select用法應該大致如下:

            while (true) {
                ...
                select(...)
                for-each socket {
                    if (FD_ISSET(fd, set))
                        ...
                }
            
                ...
            }
            

            即遍歷目前管理的fd,通過FD_ISSET去判定當前fd是否有IO事件。因為Windows的實現FD_ISSET都是一個循環,所以有了另一種不跨平臺的用法:

            while (true) {
                ...
                select(. &read_sockets, &write_sockets..)
                for-each read_socket {
                    use fd.fd_array[i)
                }
                ...
            }
            

            總結

            • Windows上select沒有fd數量的限制,但因為使用了循環來檢查,所以效率相對較低
            • Linux上selectFD_SETSIZE的限制,但其相對效率較高

            posted @ 2014-06-01 23:45 Kevin Lynx 閱讀(4937) | 評論 (1)編輯 收藏

            Muduo源碼閱讀

            最近簡單讀了下muduo的源碼,本文對其主要實現/結構簡單總結下。

            muduo的主要源碼位于net文件夾下,base文件夾是一些基礎代碼,不影響理解網絡部分的實現。muduo主要類包括:

            • EventLoop
            • Channel
            • Poller
            • TcpConnection
            • TcpClient
            • TcpServer
            • Connector
            • Acceptor
            • EventLoopThread
            • EventLoopThreadPool

            其中,Poller(及其實現類)包裝了Poll/EPoll,封裝了OS針對設備(fd)的操作;Channel是設備fd的包裝,在muduo中主要包裝socket;TcpConnection抽象一個TCP連接,無論是客戶端還是服務器只要建立了網絡連接就會使用TcpConnection;TcpClient/TcpServer分別抽象TCP客戶端和服務器;Connector/Acceptor分別包裝TCP客戶端和服務器的建立連接/接受連接;EventLoop是一個主控類,是一個事件發生器,它驅動Poller產生/發現事件,然后將事件派發到Channel處理;EventLoopThread是一個帶有EventLoop的線程;EventLoopThreadPool自然是一個EventLoopThread的資源池,維護一堆EventLoopThread。

            閱讀庫源碼時可以從庫的接口層著手,看看關鍵功能是如何實現的。對于muduo而言,可以從TcpServer/TcpClient/EventLoop/TcpConnection這幾個類著手。接下來看看主要功能的實現:

            建立連接

                TcpClient::connect 
                    -> Connector::start 
                        -> EventLoop::runInLoop(Connector::startInLoop...
                        -> Connector::connect             
            

            EventLoop::runInLoop接口用于在this所在的線程運行某個函數,這個后面看下EventLoop的實現就可以了解。 網絡連接的最終建立是在Connector::connect中實現,建立連接之后會創建一個Channel來代表這個socket,并且綁定事件監聽接口。最后最重要的是,調用Channel::enableWritingChannel有一系列的enableXX接口,這些接口用于標識自己關心某IO事件。后面會看到他們的實現。

            Connector監聽的主要事件無非就是連接已建立,用它監聽讀數據/寫數據事件也不符合設計。TcpConnection才是做這種事的。

            客戶端收發數據

            當Connector發現連接真正建立好后,會回調到TcpClient::newConnection,在TcpClient構造函數中:

                connector_->setNewConnectionCallback(
                  boost::bind(&TcpClient::newConnection, this, _1));
            

            TcpClient::newConnection中創建一個TcpConnection來代表這個連接:

                TcpConnectionPtr conn(new TcpConnection(loop_,
                                                        connName,
                                                        sockfd,
                                                        localAddr,
                                                        peerAddr));
            
                conn->setConnectionCallback(connectionCallback_);
                conn->setMessageCallback(messageCallback_);
                conn->setWriteCompleteCallback(writeCompleteCallback_);
                ...
                conn->connectEstablished();
            

            并同時設置事件回調,以上設置的回調都是應用層(即庫的使用者)的接口。每一個TcpConnection都有一個Channel,畢竟每一個網絡連接都對應了一個socket fd。在TcpConnection構造函數中創建了一個Channel,并設置事件回調函數。

            TcpConnection::connectEstablished函數最主要的是通知Channel自己開始關心IO讀取事件:

                void TcpConnection::connectEstablished()
                {
                    ...
                    channel_->enableReading();
            

            這是自此我們看到的第二個Channel::enableXXX接口,這些接口是如何實現關心IO事件的呢?這個后面講到。

            muduo的數據發送都是通過TcpConnection::send完成,這個就是一般網絡庫中在不使用OS的異步IO情況下的實現:緩存應用層傳遞過來的數據,在IO設備可寫的情況下盡量寫入數據。這個主要實現在TcpConnection::sendInLoop中。

                TcpConnection::sendInLoop(....) {
                    ...
                    // if no thing in output queue, try writing directly
                    if (!channel_->isWriting() && outputBuffer_.readableBytes() == 0)  // 設備可寫且沒有緩存時立即寫入
                    { 
                        nwrote = sockets::write(channel_->fd(), data, len);
                    }
                    ...
                    // 否則加入數據到緩存,等待IO可寫時再寫
                    outputBuffer_.append(static_cast<const char*>(data)+nwrote, remaining);
                    if (!channel_->isWriting())
                    {
                        // 注冊關心IO寫事件,Poller就會對寫做檢測
                        channel_->enableWriting();
                    }
                    ...     
                }
            

            當IO可寫時,Channel就會回調TcpConnection::handleWrite(構造函數中注冊)

                void TcpConnection::handleWrite()
                {
                    ...
                    if (channel_->isWriting())
                    {
                        ssize_t n = sockets::write(channel_->fd(),
                                           outputBuffer_.peek(),
                                           outputBuffer_.readableBytes());
            

            服務器端的數據收發同客戶端機制一致,不同的是連接(TcpConnection)的建立方式不同。

            服務器接收連接

            服務器接收連接的實現在一個網絡庫中比較重要。muduo中通過Acceptor類來接收連接。在TcpClient中,其Connector通過一個關心Channel可寫的事件來通過連接已建立;在Acceptor中則是通過一個Channel可讀的事件來表示有新的連接到來:

                Acceptor::Acceptor(....) {
                    ...
                    acceptChannel_.setReadCallback(
                        boost::bind(&Acceptor::handleRead, this));
                    ... 
                }
            
                void Acceptor::handleRead()
                {
                    ...
                    int connfd = acceptSocket_.accept(&peerAddr); // 接收連接獲得一個新的socket
                    if (connfd >= 0)
                    {
                        ...
                        newConnectionCallback_(connfd, peerAddr); // 回調到TcpServer::newConnection
            

            TcpServer::newConnection中建立一個TcpConnection,并將其附加到一個EventLoopThread中,簡單來說就是給其配置一個線程:

                void TcpServer::newConnection(int sockfd, const InetAddress& peerAddr)
                {
                    ...
                    EventLoop* ioLoop = threadPool_->getNextLoop();
                    TcpConnectionPtr conn(new TcpConnection(ioLoop,
                                                            connName,
                                                            sockfd,
                                                            localAddr,
                                                            peerAddr));
                    connections_[connName] = conn;
                    ...
                    ioLoop->runInLoop(boost::bind(&TcpConnection::connectEstablished, conn));
            

            IO的驅動

            之前提到,一旦要關心某IO事件了,就調用Channel::enableXXX,這個如何實現的呢?

                class Channel {
                    ...
                    void enableReading() { events_ |= kReadEvent; update(); }
                    void enableWriting() { events_ |= kWriteEvent; update(); }
                   
                void Channel::update()
                {
                    loop_->updateChannel(this);
                }
            
                void EventLoop::updateChannel(Channel* channel)
                {
                    ...
                    poller_->updateChannel(channel);
                }
            

            最終調用到Poller::upateChannel。muduo中有兩個Poller的實現,分別是Poll和EPoll,可以選擇簡單的Poll來看:

                void PollPoller::updateChannel(Channel* channel)
                {
                  ...
                  if (channel->index() < 0)
                  {
                    // a new one, add to pollfds_
                    assert(channels_.find(channel->fd()) == channels_.end());
                    struct pollfd pfd;
                    pfd.fd = channel->fd();
                    pfd.events = static_cast<short>(channel->events()); // 也就是Channel::enableXXX操作的那個events_
                    pfd.revents = 0;
                    pollfds_.push_back(pfd); // 加入一個新的pollfd
                    int idx = static_cast<int>(pollfds_.size())-1;
                    channel->set_index(idx);
                    channels_[pfd.fd] = channel;
            

            可見Poller就是把Channel關心的IO事件轉換為OS提供的IO模型數據結構上。通過查看關鍵的pollfds_的使用,可以發現其主要是在Poller::poll接口里。這個接口會在EventLoop的主循環中不斷調用:

                void EventLoop::loop()
                {
                  ...
                  while (!quit_)
                  {
                    activeChannels_.clear();
                    pollReturnTime_ = poller_->poll(kPollTimeMs, &activeChannels_);
                    ...
                    for (ChannelList::iterator it = activeChannels_.begin();
                        it != activeChannels_.end(); ++it)
                    {
                      currentActiveChannel_ = *it;
                      currentActiveChannel_->handleEvent(pollReturnTime_); // 獲得IO事件,通知各注冊回調
                    }
            

            整個流程可總結為:各Channel內部會把自己關心的事件告訴給Poller,Poller由EventLoop驅動檢測IO,然后返回哪些Channel發生了事件,EventLoop再驅動這些Channel調用各注冊回調。

            從這個過程中可以看出,EventLoop就是一個事件產生器。

            線程模型

            在muduo的服務器中,muduo的線程模型是怎樣的呢?它如何通過線程來支撐高并發呢?其實很簡單,它為每一個線程配置了一個EventLoop,這個線程同時被附加了若干個網絡連接,這個EventLoop服務于這些網絡連接,為這些連接收集并派發IO事件。

            回到TcpServer::newConnection中:

                void TcpServer::newConnection(int sockfd, const InetAddress& peerAddr)
                {
                  ...
                  EventLoop* ioLoop = threadPool_->getNextLoop();
                  ...
                  TcpConnectionPtr conn(new TcpConnection(ioLoop, // 使用這個選擇到的線程中的EventLoop
                                                          connName,
                                                          sockfd,
                                                          localAddr,
                                                          peerAddr));
                  ...
                  ioLoop->runInLoop(boost::bind(&TcpConnection::connectEstablished, conn));
            

            注意TcpConnection::connectEstablished是如何通過Channel注冊關心的IO事件到ioLoop的。

            極端來說,muduo的每一個連接線程可以只為一個網絡連接服務,這就有點類似于thread per connection模型了。

            網絡模型

            傳說中的Reactor模式,以及one loop per thread,基于EventLoop的作用,以及線程池與TcpConnection的關系,可以醍醐灌頂般理解以下這張muduo的網絡模型圖了:


            總結

            本文主要對muduo的主要結構及主要機制的實現做了描述,其他如Buffer的實現、定時器的實現大家都可以自行研究。muduo的源碼很清晰,通過源碼及配合陳碩博客上的內容可以學到一些網絡編程方面的經驗。

            posted @ 2014-05-04 18:22 Kevin Lynx 閱讀(14577) | 評論 (3)編輯 收藏

            記一次堆棧平衡錯誤

            最近在一個使用Visual Studio開發的C++程序中,出現了如下錯誤:

            Run-Time Check Failure #0 - The value of ESP was not properly saved across a function call. This is usually a result of calling a function declared with one calling convention with a function pointer declared with a different calling convention.

            這個錯誤主要指的就是函數調用堆棧不平衡。在C/C++程序中,調用一個函數前會保存當前堆棧信息,目標函數返回后會把堆棧恢復到調用前的狀態。函數的參數、局部變量會影響堆棧。而函數堆棧不平衡,一般是因為函數調用方式和目標函數定義方式不一致導致,例如:

            void __stdcall func(int a) {
            }
            
            int main(int argc, char* argv[]) {
                typedef void (*funcptr)(int);
                funcptr ptr = (funcptr) func;
                ptr(1); // 返回后導致堆棧不平衡
                return 0;
            }
            

            __stdcall修飾的函數,其函數參數的出棧由被調用者自己完成,而__cdecl,也就是C/C++函數的默認調用約定,則是調用者完成參數出棧。

            Visual Studio在debug模式下會在我們的代碼中加入不少檢查代碼,例如以上代碼對應的匯編中,就會增加一個檢查堆棧是否平衡的函數調用,當出現問題時,就會出現提示Run-Time Check Failure...這樣的錯誤對話框:

            call dword ptr [ptr]  ; ptr(1)
            add  esp,4  ; cdecl方式,調用者清除參數
            cmp  esi,esp  
            call @ILT+1345(__RTC_CheckEsp) (0B01546h) ; 檢查堆棧是否平衡
            

            但是我們的程序不是這種低級錯誤。我們調用的函數是放在dll中的,調用約定顯示定義為__stdcall,函數聲明和實現一致。大致的結構如下:

            IParser *parser = CreateParser();
            parser->Begin();
            ...
            ...
            parser->End();
            parser->Release(); // 返回后導致堆棧不平衡
            

            IParser的實現在一個dll里,這反而是一個誤導人的信息。parser->Release返回后,堆棧不平衡,并且僅僅少了一個字節。一個字節怎么來的?

            解決這個問題主要的手段就是跟反匯編,在關鍵位置查看寄存器和堆棧的內容。編譯器生成的代碼是正確的,而我們自己的代碼乍看上去也沒問題。最后甚至使用最傻逼的調試手段–逐行語句注釋查錯。

            具體查錯過程就不細說了。解決問題往往需要更多的冷靜,和清晰的思路。最終我使用的方法是,在進入Release之前記錄堆棧指針的值,堆棧指針的值會被壓入堆棧,以在函數返回后從堆棧彈出,恢復堆棧指針。Release的實現很簡單,就是刪除一個Parser這個對象,但這個對象的析構會導致很多其他對象被析構。我就逐層地檢查,是在哪個函數里改變了堆棧里的內容。

            理論上,函數本身是操作不到調用者的堆棧的。而現在看來,確實是被調用函數,也就是Release改寫了調用者的堆棧內容。要改變堆棧的內容,只有通過局部變量的地址才能做到。

            最終,我發現在調用完以下函數后,我跟蹤的堆棧地址內容發生了改變:

            call llvm::RefCountedBase<clang::TargetOptions>::Release (10331117h)
            

            因為注意到TargetOptions這個字眼,想起了在parser->Begin里有涉及到這個類的使用,類似于:

            TargetOptions TO;
            ...
            TargetInfo *TI = TargetInfo::CreateTargetInfo(m_inst.getDiagnostics(), TO);
            

            這部分初始化代碼,是直接從網上復制的,因為并不影響主要邏輯,所以從來沒對這塊代碼深究。查看CreateTargetInfo的源碼,發現這個函數將TO這個局部變量的地址保存了下來

            而在Release中,則會對這個保存的臨時變量進行刪除操作,形如:

            void Delete() const {
              assert (ref_cnt > 0 && "Reference count is already zero.");
              if (--ref_cnt == 0) delete static_cast<const Derived*>(this);
            }
            

            但是,問題并不在于對一個局部變量地址進行deletedelete在調試模式下是做了內存檢測的,那會導致一種斷言。

            TargetOptions包含了ref_cnt這個成員。當出了Begin作用域后,parser保存的TargetOptions的地址,指向的內容(堆棧)發生了改變,也就是ref_cnt這個成員變量的值不再正常。由于一些巧合,主要是代碼中各個局部變量、函數調用順序、函數參數個數(曾嘗試去除Begin的參數,可以避免錯誤提示),導致在調用Release前堆棧指針恰好等于之前保存的TargetOptions的地址。注意,之前保存的TargetOptions的地址,和調用Release前的堆棧指針值相同了。

            而在TargetOptionsDelete函數中,進行了--ref_cnt,這個變量是TargetOptions的第一個成員,它的減1,也就導致了堆棧內容的改變。

            至此,整個來龍去脈算是摸清。

            posted @ 2013-08-15 23:01 Kevin Lynx 閱讀(5757) | 評論 (1)編輯 收藏

            僅列出標題
            共12頁: 1 2 3 4 5 6 7 8 9 Last 
            99久久这里只精品国产免费 | 久久精品国产亚洲AV不卡| 91性高湖久久久久| 亚洲国产成人久久综合区| 久久影院综合精品| 久久久91人妻无码精品蜜桃HD| 一本色道久久88精品综合| 国产精品成人99久久久久91gav | 久久偷看各类wc女厕嘘嘘| 久久99久久无码毛片一区二区| A级毛片无码久久精品免费| 久久99精品国产麻豆蜜芽| 久久人人爽人人爽人人AV东京热| 女同久久| 精品一区二区久久久久久久网站| 久久精品中文无码资源站| 久久五月精品中文字幕| 精品久久久久久亚洲| 久久偷看各类wc女厕嘘嘘| 综合久久一区二区三区| 久久精品国产精品亚洲艾草网美妙| 国产婷婷成人久久Av免费高清| 中文字幕久久亚洲一区| 天天做夜夜做久久做狠狠| 成人国内精品久久久久影院VR| 99国产精品久久| 久久久av波多野一区二区| 日韩精品久久久肉伦网站| 四虎国产精品成人免费久久| 热久久最新网站获取| 欧美伊人久久大香线蕉综合69| 国产午夜精品久久久久九九| 国产91色综合久久免费| 国产精品99久久精品| 91精品国产91久久综合| 国内精品伊人久久久久AV影院| 久久精品国产精品亚洲毛片| 国内精品久久久久久99蜜桃| 国产精品久久久久久影院| 亚洲乱亚洲乱淫久久| 99热精品久久只有精品|