<rt id="bn8ez"></rt>
<label id="bn8ez"></label>

  • <span id="bn8ez"></span>

    <label id="bn8ez"><meter id="bn8ez"></meter></label>

    咖啡伴侶

    呆在上海
    posts - 163, comments - 156, trackbacks - 0, articles - 2

    goroutine背后的系統知識

    Posted on 2013-07-30 08:58 oathleo 閱讀(784) 評論(0)  編輯  收藏 所屬分類: Golang

    o語言從誕生到普及已經三年了,先行者大都是Web開發的背景,也有了一些普及型的書籍,可系統開發背景的人在學習這些書籍的時候,總有語焉不詳的感覺,網上也有若干流傳甚廣的文章,可其中或多或少總有些與事實不符的技術描述。希望這篇文章能為比較缺少系統編程背景的Web開發人員介紹一下goroutine背后的系統知識。

    1. 操作系統與運行庫
    2. 并發與并行 (Concurrency and Parallelism)
    3. 線程的調度
    4. 并發編程框架
    5. goroutine

    1. 操作系統與運行庫

    對于普通的電腦用戶來說,能理解應用程序是運行在操作系統之上就足夠了,可對于開發者,我們還需要了解我們寫的程序是如何在操作系統之上運行起來的,操作系統如何為應用程序提供服務,這樣我們才能分清楚哪些服務是操作系統提供的,而哪些服務是由我們所使用的語言的運行庫提供的。

    除了內存管理、文件管理、進程管理、外設管理等等內部模塊以外,操作系統還提供了許多外部接口供應用程序使用,這些接口就是所謂的“系統調用”。從DOS時代開始,系統調用就是通過軟中斷的形式來提供,也就是著名的INT 21,程序把需要調用的功能編號放入AH寄存器,把參數放入其他指定的寄存器,然后調用INT 21,中斷返回后,程序從指定的寄存器(通常是AL)里取得返回值。這樣的做法一直到奔騰2也就是P6出來之前都沒有變,譬如windows通過INT 2E提供系統調用,Linux則是INT 80,只不過后來的寄存器比以前大一些,而且可能再多一層跳轉表查詢。后來,Intel和AMD分別提供了效率更高的SYSENTER/SYSEXIT和SYSCALL/SYSRET指令來代替之前的中斷方式,略過了耗時的特權級別檢查以及寄存器壓棧出棧的操作,直接完成從RING 3代碼段到RING 0代碼段的轉換。

    系統調用都提供什么功能呢?用操作系統的名字加上對應的中斷編號到谷歌上一查就可以得到完整的列表 (WindowsLinux),這個列表就是操作系統和應用程序之間溝通的協議,如果需要超出此協議的功能,我們就只能在自己的代碼里去實現,譬如,對于內存管理,操作系統只提供進程級別的內存段的管理,譬如Windows的virtualmemory系列,或是Linux的brk,操作系統不會去在乎應用程序如何為新建對象分配內存,或是如何做垃圾回收,這些都需要應用程序自己去實現。如果超出此協議的功能無法自己實現,那我們就說該操作系統不支持該功能,舉個例子,Linux在2.6之前是不支持多線程的,無論如何在程序里模擬,我們都無法做出多個可以同時運行的并符合POSIX 1003.1c語義標準的調度單元。

    可是,我們寫程序并不需要去調用中斷或是SYSCALL指令,這是因為操作系統提供了一層封裝,在Windows上,它是NTDLL.DLL,也就是常說的Native API,我們不但不需要去直接調用INT 2E或SYSCALL,準確的說,我們不能直接去調用INT 2E或SYSCALL,因為Windows并沒有公開其調用規范,直接使用INT 2E或SYSCALL無法保證未來的兼容性。在Linux上則沒有這個問題,系統調用的列表都是公開的,而且Linus非常看重兼容性,不會去做任何更改,glibc里甚至專門提供了syscall(2)來方便用戶直接用編號調用,不過,為了解決glibc和內核之間不同版本兼容性帶來的麻煩,以及為了提高某些調用的效率(譬如__NR_ gettimeofday),Linux上還是對部分系統調用做了一層封裝,就是VDSO (早期叫linux-gate.so)。

    可是,我們寫程序也很少直接調用NTDLL或者VDSO,而是通過更上一層的封裝,這一層處理了參數準備和返回值格式轉換、以及出錯處理和錯誤代碼轉換,這就是我們所使用語言的運行庫,對于C語言,Linux上是glibc,Windows上是kernel32(或調用msvcrt),對于其他語言,譬如Java,則是JRE,這些“其他語言”的運行庫通常最終還是調用glibc或kernel32。

    “運行庫”這個詞其實不止包括用于和編譯后的目標執行程序進行鏈接的庫文件,也包括了腳本語言或字節碼解釋型語言的運行環境,譬如Python,C#的CLR,Java的JRE。

    對系統調用的封裝只是運行庫的很小一部分功能,運行庫通常還提供了諸如字符串處理、數學計算、常用數據結構容器等等不需要操作系統支持的功能,同時,運行庫也會對操作系統支持的功能提供更易用更高級的封裝,譬如帶緩存和格式的IO、線程池。

    所以,在我們說“某某語言新增了某某功能”的時候,通常是這么幾種可能:
    1. 支持新的語義或語法,從而便于我們描述和解決問題。譬如Java的泛型、Annotation、lambda表達式。
    2. 提供了新的工具或類庫,減少了我們開發的代碼量。譬如Python 2.7的argparse
    3. 對系統調用有了更良好更全面的封裝,使我們可以做到以前在這個語言環境里做不到或很難做到的事情。譬如Java NIO

    但任何一門語言,包括其運行庫和運行環境,都不可能創造出操作系統不支持的功能,Go語言也是這樣,不管它的特性描述看起來多么炫麗,那必然都是其他語言也可以做到的,只不過Go提供了更方便更清晰的語義和支持,提高了開發的效率。

    2. 并發與并行 (Concurrency and Parallelism)

    并發是指程序的邏輯結構。非并發的程序就是一根竹竿捅到底,只有一個邏輯控制流,也就是順序執行的(Sequential)程序,在任何時刻,程序只會處在這個邏輯控制流的某個位置。而如果某個程序有多個獨立的邏輯控制流,也就是可以同時處理(deal)多件事情,我們就說這個程序是并發的。這里的“同時”,并不一定要是真正在時鐘的某一時刻(那是運行狀態而不是邏輯結構),而是指:如果把這些邏輯控制流畫成時序流程圖,它們在時間線上是可以重疊的。

    并行是指程序的運行狀態。如果一個程序在某一時刻被多個CPU流水線同時進行處理,那么我們就說這個程序是以并行的形式在運行。(嚴格意義上講,我們不能說某程序是“并行”的,因為“并行”不是描述程序本身,而是描述程序的運行狀態,但這篇小文里就不那么咬文嚼字,以下說到“并行”的時候,就是指代“以并行的形式運行”)顯然,并行一定是需要硬件支持的。

    而且不難理解:

    1. 并發是并行的必要條件,如果一個程序本身就不是并發的,也就是只有一個邏輯控制流,那么我們不可能讓其被并行處理。

    2. 并發不是并行的充分條件,一個并發的程序,如果只被一個CPU流水線進行處理(通過分時),那么它就不是并行的。

    3. 并發只是更符合現實問題本質的表達方式,并發的最初目的是簡化代碼邏輯,而不是使程序運行的更快;

    這幾段略微抽象,我們可以用一個最簡單的例子來把這些概念實例化:用C語言寫一個最簡單的HelloWorld,它就是非并發的,如果我們建立多個線程,每個線程里打印一個HelloWorld,那么這個程序就是并發的,如果這個程序運行在老式的單核CPU上,那么這個并發程序還不是并行的,如果我們用多核多CPU且支持多任務的操作系統來運行它,那么這個并發程序就是并行的。

    還有一個略微復雜的例子,更能說明并發不一定可以并行,而且并發不是為了效率,就是Go語言例子里計算素數的sieve.go。我們從小到大針對每一個因子啟動一個代碼片段,如果當前驗證的數能被當前因子除盡,則該數不是素數,如果不能,則把該數發送給下一個因子的代碼片段,直到最后一個因子也無法除盡,則該數為素數,我們再啟動一個它的代碼片段,用于驗證更大的數字。這是符合我們計算素數的邏輯的,而且每個因子的代碼處理片段都是相同的,所以程序非常的簡潔,但它無法被并行,因為每個片段都依賴于前一個片段的處理結果和輸出。

    并發可以通過以下方式做到:

    1. 顯式地定義并觸發多個代碼片段,也就是邏輯控制流,由應用程序或操作系統對它們進行調度。它們可以是獨立無關的,也可以是相互依賴需要交互的,譬如上面提到的素數計算,其實它也是個經典的生產者和消費者的問題:兩個邏輯控制流A和B,A產生輸出,當有了輸出后,B取得A的輸出進行處理。線程只是實現并發的其中一個手段,除此之外,運行庫或是應用程序本身也有多種手段來實現并發,這是下節的主要內容。

    2. 隱式地放置多個代碼片段,在系統事件發生時觸發執行相應的代碼片段,也就是事件驅動的方式,譬如某個端口或管道接收到了數據(多路IO的情況下),再譬如進程接收到了某個信號(signal)。

    并行可以在四個層面上做到:

    1. 多臺機器。自然我們就有了多個CPU流水線,譬如Hadoop集群里的MapReduce任務。

    2. 多CPU。不管是真的多顆CPU還是多核還是超線程,總之我們有了多個CPU流水線。

    3. 單CPU核里的ILP(Instruction-level parallelism),指令級并行。通過復雜的制造工藝和對指令的解析以及分支預測和亂序執行,現在的CPU可以在單個時鐘周期內執行多條指令,從而,即使是非并發的程序,也可能是以并行的形式執行。

    4. 單指令多數據(Single instruction, multiple data. SIMD),為了多媒體數據的處理,現在的CPU的指令集支持單條指令對多條數據進行操作。

    其中,1牽涉到分布式處理,包括數據的分布和任務的同步等等,而且是基于網絡的。3和4通常是編譯器和CPU的開發人員需要考慮的。這里我們說的并行主要針對第2種:單臺機器內的多核CPU并行。

    關于并發與并行的問題,Go語言的作者Rob Pike專門就此寫過一個幻燈片:http://talks.golang.org/2012/waza.slide

    在CMU那本著名的《Computer Systems: A Programmer’s Perspective》里的這張圖也非常直觀清晰:

    3. 線程的調度

    上一節主要說的是并發和并行的概念,而線程是最直觀的并發的實現,這一節我們主要說操作系統如何讓多個線程并發的執行,當然在多CPU的時候,也就是并行的執行。我們不討論進程,進程的意義是“隔離的執行環境”,而不是“單獨的執行序列”。

    我們首先需要理解IA-32 CPU的指令控制方式,這樣才能理解如何在多個指令序列(也就是邏輯控制流)之間進行切換。CPU通過CS:EIP寄存器的值確定下一條指令的位置,但是CPU并不允許直接使用MOV指令來更改EIP的值,必須通過JMP系列指令、CALL/RET指令、或INT中斷指令來實現代碼的跳轉;在指令序列間切換的時候,除了更改EIP之外,我們還要保證代碼可能會使用到的各個寄存器的值,尤其是棧指針SS:ESP,以及EFLAGS標志位等,都能夠恢復到目標指令序列上次執行到這個位置時候的狀態。

    線程是操作系統對外提供的服務,應用程序可以通過系統調用讓操作系統啟動線程,并負責隨后的線程調度和切換。我們先考慮單顆單核CPU,操作系統內核與應用程序其實是也是在共享同一個CPU,當EIP在應用程序代碼段的時候,內核并沒有控制權,內核并不是一個進程或線程,內核只是以實模式運行的,代碼段權限為RING 0的內存中的程序,只有當產生中斷或是應用程序呼叫系統調用的時候,控制權才轉移到內核,在內核里,所有代碼都在同一個地址空間,為了給不同的線程提供服務,內核會為每一個線程建立一個內核堆棧,這是線程切換的關鍵。通常,內核會在時鐘中斷里或系統調用返回前(考慮到性能,通常是在不頻繁發生的系統調用返回前),對整個系統的線程進行調度,計算當前線程的剩余時間片,如果需要切換,就在“可運行”的線程隊列里計算優先級,選出目標線程后,則保存當前線程的運行環境,并恢復目標線程的運行環境,其中最重要的,就是切換堆棧指針ESP,然后再把EIP指向目標線程上次被移出CPU時的指令。Linux內核在實現線程切換時,耍了個花槍,它并不是直接JMP,而是先把ESP切換為目標線程的內核棧,把目標線程的代碼地址壓棧,然后JMP到__switch_to(),相當于偽造了一個CALL __switch_to()指令,然后,在__switch_to()的最后使用RET指令返回,這樣就把棧里的目標線程的代碼地址放入了EIP,接下來CPU就開始執行目標線程的代碼了,其實也就是上次停在switch_to這個宏展開的地方。

    這里需要補充幾點:(1) 雖然IA-32提供了TSS (Task State Segment),試圖簡化操作系統進行線程調度的流程,但由于其效率低下,而且并不是通用標準,不利于移植,所以主流操作系統都沒有去利用TSS。更嚴格的說,其實還是用了TSS,因為只有通過TSS才能把堆棧切換到內核堆棧指針SS0:ESP0,但除此之外的TSS的功能就完全沒有被使用了。(2) 線程從用戶態進入內核的時候,相關的寄存器以及用戶態代碼段的EIP已經保存了一次,所以,在上面所說的內核態線程切換時,需要保存和恢復的內容并不多。(3) 以上描述的都是搶占式(preemptively)的調度方式,內核以及其中的硬件驅動也會在等待外部資源可用的時候主動調用schedule(),用戶態的代碼也可以通過sched_yield()系統調用主動發起調度,讓出CPU。

    現在我們一臺普通的PC或服務里通常都有多顆CPU (physical package),每顆CPU又有多個核 (processor core),每個核又可以支持超線程 (two logical processors for each core),也就是邏輯處理器。每個邏輯處理器都有自己的一套完整的寄存器,其中包括了CS:EIP和SS:ESP,從而,以操作系統和應用的角度來看,每個邏輯處理器都是一個單獨的流水線。在多處理器的情況下,線程切換的原理和流程其實和單處理器時是基本一致的,內核代碼只有一份,當某個CPU上發生時鐘中斷或是系統調用時,該CPU的CS:EIP和控制權又回到了內核,內核根據調度策略的結果進行線程切換。但在這個時候,如果我們的程序用線程實現了并發,那么操作系統可以使我們的程序在多個CPU上實現并行。

    這里也需要補充兩點:(1) 多核的場景里,各個核之間并不是完全對等的,譬如在同一個核上的兩個超線程是共享L1/L2緩存的;在有NUMA支持的場景里,每個核訪問內存不同區域的延遲是不一樣的;所以,多核場景里的線程調度又引入了“調度域”(scheduling domains)的概念,但這不影響我們理解線程切換機制。(2) 多核的場景下,中斷發給哪個CPU?軟中斷(包括除以0,缺頁異常,INT指令)自然是在觸發該中斷的CPU上產生,而硬中斷則又分兩種情況,一種是每個CPU自己產生的中斷,譬如時鐘,這是每個CPU處理自己的,還有一種是外部中斷,譬如IO,可以通過APIC來指定其送給哪個CPU;因為調度程序只能控制當前的CPU,所以,如果IO中斷沒有進行均勻的分配的話,那么和IO相關的線程就只能在某些CPU上運行,導致CPU負載不均,進而影響整個系統的效率。

    4. 并發編程框架

    以上大概介紹了一個用多線程來實現并發的程序是如何被操作系統調度以及并行執行(在有多個邏輯處理器時),同時大家也可以看到,代碼片段或者說邏輯控制流的調度和切換其實并不神秘,理論上,我們也可以不依賴操作系統和其提供的線程,在自己程序的代碼段里定義多個片段,然后在我們自己程序里對其進行調度和切換。

    為了描述方便,我們接下來把“代碼片段”稱為“任務”。

    和內核的實現類似,只是我們不需要考慮中斷和系統調用,那么,我們的程序本質上就是一個循環,這個循環本身就是調度程序schedule(),我們需要維護一個任務的列表,根據我們定義的策略,先進先出或是有優先級等等,每次從列表里挑選出一個任務,然后恢復各個寄存器的值,并且JMP到該任務上次被暫停的地方,所有這些需要保存的信息都可以作為該任務的屬性,存放在任務列表里。

    看起來很簡單啊,可是我們還需要解決幾個問題:

    (1) 我們運行在用戶態,是沒有中斷或系統調用這樣的機制來打斷代碼執行的,那么,一旦我們的schedule()代碼把控制權交給了任務的代碼,我們下次的調度在什么時候發生?答案是,不會發生,只有靠任務主動調用schedule(),我們才有機會進行調度,所以,這里的任務不能像線程一樣依賴內核調度從而毫無顧忌的執行,我們的任務里一定要顯式的調用schedule(),這就是所謂的協作式(cooperative)調度。(雖然我們可以通過注冊信號處理函數來模擬內核里的時鐘中斷并取得控制權,可問題在于,信號處理函數是由內核調用的,在其結束的時候,內核重新獲得控制權,隨后返回用戶態并繼續沿著信號發生時被中斷的代碼路徑執行,從而我們無法在信號處理函數內進行任務切換)

    (2) 堆棧。和內核調度線程的原理一樣,我們也需要為每個任務單獨分配堆棧,并且把其堆棧信息保存在任務屬性里,在任務切換時也保存或恢復當前的SS:ESP。任務堆棧的空間可以是在當前線程的堆棧上分配,也可以是在堆上分配,但通常是在堆上分配比較好:幾乎沒有大小或任務總數的限制、堆棧大小可以動態擴展(gcc有split stack,但太復雜了)、便于把任務切換到其他線程。

    到這里,我們大概知道了如何構造一個并發的編程框架,可如何讓任務可以并行的在多個邏輯處理器上執行呢?只有內核才有調度CPU的權限,所以,我們還是必須通過系統調用創建線程,才可以實現并行。在多線程處理多任務的時候,我們還需要考慮幾個問題:

    (1) 如果某個任務發起了一個系統調用,譬如長時間等待IO,那當前線程就被內核放入了等待調度的隊列,豈不是讓其他任務都沒有機會執行?

    在單線程的情況下,我們只有一個解決辦法,就是使用非阻塞的IO系統調用,并讓出CPU,然后在schedule()里統一進行輪詢,有數據時切換回該fd對應的任務;效率略低的做法是不進行統一輪詢,讓各個任務在輪到自己執行時再次用非阻塞方式進行IO,直到有數據可用。

    如果我們采用多線程來構造我們整個的程序,那么我們可以封裝系統調用的接口,當某個任務進入系統調用時,我們就把當前線程留給它(暫時)獨享,并開啟新的線程來處理其他任務。

    (2) 任務同步。譬如我們上節提到的生產者和消費者的例子,如何讓消費者在數據還沒有被生產出來的時候進入等待,并且在數據可用時觸發消費者繼續執行呢?

    在單線程的情況下,我們可以定義一個結構,其中有變量用于存放交互數據本身,以及數據的當前可用狀態,以及負責讀寫此數據的兩個任務的編號。然后我們的并發編程框架再提供read和write方法供任務調用,在read方法里,我們循環檢查數據是否可用,如果數據還不可用,我們就調用schedule()讓出CPU進入等待;在write方法里,我們往結構里寫入數據,更改數據可用狀態,然后返回;在schedule()里,我們檢查數據可用狀態,如果可用,則激活需要讀取此數據的任務,該任務繼續循環檢測數據是否可用,發現可用,讀取,更改狀態為不可用,返回。代碼的簡單邏輯如下:

    struct chan { bool ready, int data };  int read (struct chan *c) { while (1) { if (c->ready) {             c->ready = false; return c->data; } else {             schedule(); } } }  void write (struct chan *c, int i) { while (1) { if (c->ready) {             schedule(); } else {             c->data = i;             c->ready = true;             schedule(); // optional return; } } }

    很顯然,如果是多線程的話,我們需要通過線程庫或系統調用提供的同步機制來保護對這個結構體內數據的訪問。

    以上就是最簡化的一個并發框架的設計考慮,在我們實際開發工作中遇到的并發框架可能由于語言和運行庫的不同而有所不同,在功能和易用性上也可能各有取舍,但底層的原理都是殊途同歸。

    譬如,glic里的getcontext/setcontext/swapcontext系列庫函數可以方便的用來保存和恢復任務執行狀態;Windows提供了Fiber系列的SDK API;這二者都不是系統調用,getcontext和setcontext的man page雖然是在section 2,但那只是SVR4時的歷史遺留問題,其實現代碼是在glibc而不是kernel;CreateFiber是在kernel32里提供的,NTDLL里并沒有對應的NtCreateFiber。

    在其他語言里,我們所謂的“任務”更多時候被稱為“協程”,也就是Coroutine。譬如C++里最常用的是Boost.Coroutine;Java因為有一層字節碼解釋,比較麻煩,但也有支持協程的JVM補丁,或是動態修改字節碼以支持協程的項目;PHP和Python的generator和yield其實已經是協程的支持,在此之上可以封裝出更通用的協程接口和調度;另外還有原生支持協程的Erlang等,筆者不懂,就不說了,具體可參見Wikipedia的頁面:http://en.wikipedia.org/wiki/Coroutine

    由于保存和恢復任務執行狀態需要訪問CPU寄存器,所以相關的運行庫也都會列出所支持的CPU列表。

    從操作系統層面提供協程以及其并行調度的,好像只有OS X和iOS的Grand Central Dispatch,其大部分功能也是在運行庫里實現的。

    5. goroutine

    Go語言通過goroutine提供了目前為止所有(我所了解的)語言里對于并發編程的最清晰最直接的支持,Go語言的文檔里對其特性也描述的非常全面甚至超過了,在這里,基于我們上面的系統知識介紹,列舉一下goroutine的特性,算是小結:

    (1) goroutine是Go語言運行庫的功能,不是操作系統提供的功能,goroutine不是用線程實現的。具體可參見Go語言源碼里的pkg/runtime/proc.c

    (2) goroutine就是一段代碼,一個函數入口,以及在堆上為其分配的一個堆棧。所以它非常廉價,我們可以很輕松的創建上萬個goroutine,但它們并不是被操作系統所調度執行

    (3) 除了被系統調用阻塞的線程外,Go運行庫最多會啟動$GOMAXPROCS個線程來運行goroutine

    (4) goroutine是協作式調度的,如果goroutine會執行很長時間,而且不是通過等待讀取或寫入channel的數據來同步的話,就需要主動調用Gosched()來讓出CPU

    (5) 和所有其他并發框架里的協程一樣,goroutine里所謂“無鎖”的優點只在單線程下有效,如果$GOMAXPROCS > 1并且協程間需要通信,Go運行庫會負責加鎖保護數據,這也是為什么sieve.go這樣的例子在多CPU多線程時反而更慢的原因

    (6) Web等服務端程序要處理的請求從本質上來講是并行處理的問題,每個請求基本獨立,互不依賴,幾乎沒有數據交互,這不是一個并發編程的模型,而并發編程框架只是解決了其語義表述的復雜性,并不是從根本上提高處理的效率,也許是并發連接和并發編程的英文都是concurrent吧,很容易產生“并發編程框架和coroutine可以高效處理大量并發連接”的誤解。

    (7) Go語言運行庫封裝了異步IO,所以可以寫出貌似并發數很多的服務端,可即使我們通過調整$GOMAXPROCS來充分利用多核CPU并行處理,其效率也不如我們利用IO事件驅動設計的、按照事務類型劃分好合適比例的線程池。在響應時間上,協作式調度是硬傷。

    (8) goroutine最大的價值是其實現了并發協程和實際并行執行的線程的映射以及動態擴展,隨著其運行庫的不斷發展和完善,其性能一定會越來越好,尤其是在CPU核數越來越多的未來,終有一天我們會為了代碼的簡潔和可維護性而放棄那一點點性能的差別。

    主站蜘蛛池模板: 日韩在线观看视频免费| 国产又黄又爽又大的免费视频 | 免费观看男人免费桶女人视频| 日本亚洲欧美色视频在线播放| 久久久久亚洲精品天堂久久久久久| a毛片免费全部在线播放**| 亚洲成a人片7777| 亚洲国产午夜中文字幕精品黄网站 | 免费无码VA一区二区三区| 亚洲AV无码国产精品色| 亚洲国产成人精品无码久久久久久综合 | 边摸边吃奶边做爽免费视频网站| 国产AV无码专区亚洲AV毛网站| 成年黄网站色大免费全看| 免费无码又爽又黄又刺激网站| 4444亚洲国产成人精品| 国产成人aaa在线视频免费观看| 男人的天堂网免费网站| 亚洲国产av玩弄放荡人妇| 亚洲精品福利视频| 国产成人人综合亚洲欧美丁香花| 日韩精品亚洲专区在线影视| 亚洲小说区图片区另类春色| 91在线视频免费91| a级毛片黄免费a级毛片| 亚洲美国产亚洲AV| 精品亚洲aⅴ在线观看| 在线亚洲精品自拍| 在线播放高清国语自产拍免费 | 国产亚洲成av人片在线观看| 亚洲午夜精品一级在线播放放| 国产在线观看免费不卡| 日韩一区二区三区免费体验| 美女被免费视频网站a国产| 成人免费男女视频网站慢动作 | 亚洲一区中文字幕在线观看| 亚洲综合综合在线| 亚洲白色白色永久观看| 亚洲最新黄色网址| 亚洲国产精品日韩在线| 色在线亚洲视频www|