開始講netpoller之前先講一下socket網路程式設計和epoll,不管go也好,nginx也好,只要是開啟網路服務就繞不開socket,它是網路服務的基礎。下一章網路程式設計先講講socket的基礎函式。
socket(購買電話機)
int socket(int domain, int type, int protocol);
socket函式對應於普通檔案的開啟操作。普通檔案的開啟操作返回一個檔案描述字,而socket()用於建立一個socket描述符(socket descriptor),它唯一標識一個socket。這個socket描述字跟檔案描述字一樣,後續的操作都有用到它,把它作為引數,通過它來進行一些讀寫操作。
正如可以給fopen的傳入不同引數值,以開啟不同的檔案。建立socket的時候,也可以指定不同的引數建立不同的socket描述符,socket函式的三個引數分別為:
domain:即協議域,又稱為協議族(family)。常用的協議族有,AF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(或稱AF_UNIX,Unix域socket)、AF_ROUTE等等。協議族決定了socket的地址型別,在通訊中必須採用對應的地址,如AF_INET決定了要用ipv4地址(32位的)與埠號(16位的)的組合、AF_UNIX決定了要用一個絕對路徑名作為地址。
type:指定socket型別。常用的socket型別有,SOCK_STREAM(流式套接字)、SOCK_DGRAM(資料包式套接字)、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等 ,linux2.1.17版本後增加了SOCK_NONBLOCK,代表了非阻塞模式
示例:
int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);
protocol:就是指定協議。常用的協議有,IPPROTO_TCP、PPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它們分別對應TCP傳輸協議、UDP傳輸協議、STCP傳輸協議、TIPC傳輸協議。
bind(接電話線)
bind()函式把一個地址族中的特定地址賦給socket。例如對應AF_INET、AF_INET6就是把一個ipv4或ipv6地址和埠號組合賦給socket。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函式的三個引數分別為:
sockfd:即socket描述字,它是通過socket()函式建立了,唯一標識一個socket。bind()函式就是將給這個描述字繫結一個名字
addr:一個const struct sockaddr *指標,指向要繫結給sockfd的協議地址,例如”127.0.0.1:80”
addrlen: 對應的是地址的長度。
listen(等電話中)
int listen(int sockfd, int backlog);
backlog: socket可以排隊的最大連線個數
當有多個客戶端一起請求的時候,服務端不可能來多少就處理多少,這樣如果併發太多,就會因為效能的因素髮生擁塞,然後造成雪崩。所有就搞了一個佇列,先將請求放在佇列裡面,一個個來。socket_listen裡面的第二個引數backlog就是設定這個佇列的長度。如果將佇列長度設定成10,那麼如果有20個請求一起過來,服務端就會先放10個請求進入這個佇列,因為長度只有10。然後其他的就直接拒絕。tcp協議這時候不會傳送rst給客戶端,這樣的話客戶端就會重新傳送SYN,以便能進入這個佇列。
如果三次握手完成了,就會將完成三次握手的請求取出來,放入另一個佇列中,這樣佇列就空出一個位置,其他重發SYN的請求就可以進入佇列中。
connect(打電話)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockaddr:socket地址,”127.0.0.1:8080”
addrlen:地址長度
accept(接電話)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockaddr:一個指標,接收客戶端socket地址,”127.0.0.1:36820”
addrlen:客戶端地址長度
read/write/recv/send (通話)
read函式是負責從fd中讀取內容。
write函式將buf中的nbytes位元組內容寫入檔案描述符fd。
所謂 I/O 多路複用指的就是 select/poll/epoll 這一系列的多路選擇器:支援單一執行緒同時監聽多個檔案描述符(I/O 事件),阻塞等待,並在其中某個檔案描述符可讀寫時收到通知。 I/O 複用其實複用的不是 I/O 連線,而是複用執行緒,讓一個 thread of control 能夠處理多個連線(I/O 事件)。
select
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// 和 select 緊密結合的四個巨集:
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
理解 select 的關鍵在於理解 fd_set,為說明方便,取 fd_set 長度為 1 位元組,fd_set 中的每一 bit 可以對應一個檔案描述符 fd,則 1 位元組長的 fd_set 最大可以對應 8 個 fd。select 的呼叫過程如下:
- 執行 FD_ZERO(&set), 則 set 用位表示是
0000,0000
- 若 fd=5, 執行 FD_SET(fd, &set); 後 set 變為 0001,0000(第 5 位置為 1)
- 再加入 fd=2, fd=1,則 set 變為
0001,0011
- 執行 select(6, &set, 0, 0, 0) 阻塞等待
- 若 fd=1, fd=2 上都發生可讀事件,則 select 返回,此時 set 變為
0000,0011
(注意:沒有事件發生的 fd=5 被清空)
基於上面的呼叫過程,可以得出 select 的特點:
- 可監控的檔案描述符個數取決於 sizeof(fd_set) 的值。假設伺服器上 sizeof(fd_set)=512,每 bit 表示一個檔案描述符,則伺服器上支援的最大檔案描述符是 512*8=4096。
- 將 fd 加入 select 監控集的同時,還要再使用一個資料結構 array 儲存放到 select 監控集中的 fd,一是用於在 select 返回後,array 作為源資料和 fd_set 進行 FD_ISSET 判斷。二是 select 返回後會把以前加入的但並無事件發生的 fd 清空,則每次開始 select 前都要重新從 array 取得 fd 逐一加入(FD_ZERO 最先),掃描 array 的同時取得 fd 最大值 maxfd,用於 select 的第一個引數
- 可見 select 模型必須在 select 前迴圈 array(加 fd,取 maxfd),select 返回後迴圈 array(FD_ISSET 判斷是否有事件發生)
所以,select 有如下的缺點:
- 最大併發數限制:使用 32 個整數的 32 位,即 32*32=1024 來標識 fd,雖然可修改,但是有以下第 2, 3 點的瓶頸
- 每次呼叫 select,都需要把 fd 集合從使用者態拷貝到核心態,這個開銷在 fd 很多時會很大
- 效能衰減嚴重:每次 kernel 都需要線性掃描整個 fd_set,所以隨著監控的描述符 fd 數量增長,其 I/O 效能會線性下降
poll 的實現和 select 非常相似,只是描述 fd 集合的方式不同,poll 使用 pollfd 結構而不是 select 的 fd_set 結構,poll 解決了最大檔案描述符數量限制的問題,但是同樣需要從使用者態拷貝所有的 fd 到核心態,也需要線性遍歷所有的 fd 集合,所以它和 select 只是實現細節上的區分,並沒有本質上的區別。
epoll
struct eventpoll{
....
/*紅黑樹的根節點,這顆樹中儲存著所有新增到epoll中的需要監控的事件*/
struct rb_root rbr;
/*雙連結串列中則存放著將要通過epoll_wait返回給使用者的滿足條件的事件*/
struct list_head rdlist;
....
};
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
函式
epoll_create(int size);
建立一個epoll的控制程式碼,size用來告訴核心這個監聽的數目一共有多大。這個引數不同於select()中的第一個引數,給出最大監聽的fd+1的值。需要注意的是,當建立好epoll控制程式碼後,它就是會佔用一個fd值,在linux下如果檢視/proc/程式id/fd/,是能夠看到這個fd的,所以在使用完epoll後,必須呼叫close()關閉,否則可能導致fd被耗盡。
epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件註冊函式,它不同與select()是在監聽事件時告訴核心要監聽什麼型別的事件,而是在這裡先註冊要監聽的事件型別。第一個引數是epoll_create()的返回值,第二個參數列示動作,用三個巨集來表示:
EPOLL_CTL_ADD:註冊新的fd到epfd中;
EPOLL_CTL_MOD:修改已經註冊的fd的監聽事件;
EPOLL_CTL_DEL:從epfd中刪除一個fd;
第三個引數是需要監聽的fd,第四個引數是告訴核心需要監聽什麼事
events可以是以下幾個巨集的集合:
EPOLLIN :表示對應的檔案描述符可以讀(包括對端SOCKET正常關閉);
EPOLLOUT:表示對應的檔案描述符可以寫;
EPOLLPRI:表示對應的檔案描述符有緊急的資料可讀(這裡應該表示有帶外資料到來);
EPOLLERR:表示對應的檔案描述符發生錯誤;
EPOLLHUP:表示對應的檔案描述符被結束通話;
EPOLLET: 將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。
EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之後,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL佇列裡
epoll_wailt
等待事件的產生,類似於select()呼叫。引數events用來從核心得到事件的集合,maxevents告之核心這個events有多大,這個 maxevents的值不能大於建立epoll_create()時的size,引數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函式返回需要處理的事件數目,如返回0表示已超時。
優勢
由於epoll的實現機制與select/poll機制完全不同,上面所說的 select的缺點在epoll上不復存在。
設想一下如下場景:有100萬個客戶端同時與一個伺服器程式保持著TCP連線。而每一時刻,通常只有幾百上千個TCP連線是活躍的(事實上大部分場景都是這種情況)。如何實現這樣的高併發?
在select/poll時代,伺服器程式每次都把這100萬個連線告訴作業系統(從使用者態複製控制程式碼資料結構到核心態),讓作業系統核心去查詢這些套接字上是否有事件發生,輪詢完後,再將控制程式碼資料複製到使用者態,讓伺服器應用程式輪詢處理已發生的網路事件,這一過程資源消耗較大,因此,select/poll一般只能處理幾千的併發連線。
epoll的設計和實現與select完全不同。把原先的select/poll呼叫分成了3個部分:
1)呼叫epoll_create()建立一個epoll物件
2)呼叫epoll_ctl向epoll物件中新增這100萬個連線的套接字
3)呼叫epoll_wait收集發生的事件的連線
如此一來,要實現上面說是的場景,只需要在程式啟動時建立一個epoll物件,然後在需要的時候向這個epoll物件中新增或者刪除連線。同時,epoll_wait的效率也非常高,因為呼叫epoll_wait時,並沒有一股腦的向作業系統複製這100萬個連線的控制程式碼資料,核心也不需要去遍歷全部的連線。
與 select&poll 相比,epoll 分清了高頻呼叫和低頻呼叫。例如,epoll_ctl 相對來說就是非頻繁呼叫的,而 epoll_wait 則是會被高頻呼叫的。所以 epoll 利用 epoll_ctl 來插入或者刪除一個 fd,實現使用者態到核心態的資料拷貝,這確保了每一個 fd 在其生命週期只需要被拷貝一次,而不是每次呼叫 epoll_wait 的時候都拷貝一次。 epoll_wait 則被設計成幾乎沒有入參的呼叫,相比 select&poll 需要把全部監聽的 fd 集合從使用者態拷貝至核心態的做法,epoll 的效率就高出了一大截。
在實現上 epoll 採用紅黑樹來儲存所有監聽的 fd,而紅黑樹本身插入和刪除效能比較穩定,時間複雜度 O(logN)。通過 epoll_ctl 函式新增進來的 fd 都會被放在紅黑樹的某個節點內,所以,重複新增是沒有用的。當把 fd 新增進來的時候時候會完成關鍵的一步:該 fd 會與相應的裝置(網路卡)驅動程式建立回撥關係,也就是在核心中斷處理程式為它註冊一個回撥函式,在 fd 相應的事件觸發(中斷)之後(裝置就緒了),核心就會呼叫這個回撥函式,該回撥函式在核心中被稱為: ep_poll_callback
,這個回撥函式其實就是把這個 fd 新增到 rdllist 這個雙向連結串列(就緒連結串列)中。epoll_wait 實際上就是去檢查 rdllist 雙向連結串列中是否有就緒的 fd,當 rdllist 為空(無就緒 fd)時掛起當前程式,直到 rdllist 非空時程式才被喚醒並返回。
邊緣/水平觸發模式
Level_triggered(水平觸發):當被監控的檔案描述符上有可讀寫事件發生時,epoll_wait()會通知處理程式去讀寫。如果這次沒有把資料一次性全部讀寫完(如讀寫緩衝區太小),那麼下次呼叫 epoll_wait()時,它還會通知你在上沒讀寫完的檔案描述符上繼續讀寫,當然如果你一直不去讀寫,它會一直通知你!!!如果系統中有大量你不需要讀寫的就緒檔案描述符,而它們每次都會返回,這樣會大大降低處理程式檢索自己關心的就緒檔案描述符的效率
Edge_triggered(邊緣觸發):當被監控的檔案描述符上有可讀寫事件發生時,epoll_wait()會通知處理程式去讀寫。如果這次沒有把資料全部讀寫完(如讀寫緩衝區太小),那麼下次呼叫epoll_wait()時,它不會通知你,也就是它只會通知你一次,直到該檔案描述符上出現第二次可讀寫事件才會通知你!!!這種模式比水平觸發效率高,系統不會充斥大量你不關心的就緒檔案描述符
為什麼ET模式要用非阻塞socket
假設buffer有1024位元組,一次讀取的資料很大,超過1024的大小:這種情況就需要讀取多次,也就是要呼叫多次recv函式,通常我們會用while迴圈一直讀取,無論ET還是LT,這樣LT模式就顯得很雞肋,所以一般不用LT。在ET模式下,(1)、如果用阻塞IO+while迴圈,當最後一個資料讀取完後,程式是無法立刻跳出while迴圈的,因為阻塞IO會在 while(true){ int len=recv(); }這裡阻塞住,除非對方關閉連線或者recv出錯,這樣程式就無法繼續往下執行,這一次的epoll_wait沒有辦法處理其它的連線,會造成延遲、併發度下降。(2)、如果是非阻塞IO+while迴圈,當讀取完資料後,recv會立即返回-1,並將errno設定為EAGAIN或EWOULDBLOCK,這就表示資料已經讀取完成,已經沒有資料了,可以退出迴圈了。這樣就不會像阻塞IO一樣卡在那裡,這就減少了不必要的等待時間,效能自然更高。
int main(){
struct epoll_event ev, events[MAX_EVENTS];
int addrlen, listenfd, conn_sock, nfds, epfd, fd, i, nread, n;
struct sockaddr_in local, remote;
char buf[BUFSIZ];
//建立listen socket
if( (listenfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK , 0)) < 0) {
perror("sockfd\n");
exit(1);
}
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = htonl(INADDR_ANY);;
local.sin_port = htons(PORT);
if( bind(listenfd, (struct sockaddr *) &local, sizeof(local)) < 0) {
perror("bind\n");
exit(1);
}
listen(listenfd, 20);
epfd = epoll_create(MAX_EVENTS);
if (epfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
ev.events = EPOLLIN;
ev.data.fd = listenfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
for (;;) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_pwait");
exit(EXIT_FAILURE);
}
for (i = 0; i < nfds; ++i) {
fd = events[i].data.fd;
if (fd == listenfd) {
while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote,
(size_t *)&addrlen)) > 0) {
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: add");
exit(EXIT_FAILURE);
}
}
if (conn_sock == -1) {
if (errno != EAGAIN && errno != ECONNABORTED
&& errno != EPROTO && errno != EINTR)
perror("accept");
}
continue;
}
if (events[i].events & EPOLLIN) {
n = 0;
while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {
n += nread;
}
if (nread == -1 && errno != EAGAIN) {
perror("read error");
}
ev.data.fd = fd;
ev.events = events[i].events | EPOLLOUT;
if (epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev) == -1) {
perror("epoll_ctl: mod");
}
}
if (events[i].events & EPOLLOUT) {
sprintf(buf, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\nHello World", 11);
int nwrite, data_size = strlen(buf);
n = data_size;
while (n > 0) {
nwrite = write(fd, buf + data_size - n, n);
if (nwrite < n) {
if (nwrite == -1 && errno != EAGAIN) {
perror("write error");
}
break;
}
n -= nwrite;
}
close(fd);
}
}
}
return 0;
}
Go netpoller 基本原理
Go netpoller 通過在底層對 epoll/kqueue/iocp 的封裝,從而實現了使用同步程式設計模式達到非同步執行的效果。總結來說,所有的網路操作都以網路描述符 netFD 為中心實現。netFD 與底層 PollDesc 結構繫結,當在一個 netFD 上讀寫遇到 EAGAIN 錯誤時,就將當前 goroutine 儲存到這個 netFD 對應的 PollDesc 中,同時呼叫 gopark 把當前 goroutine 給 park 住,直到這個 netFD 上再次發生讀寫事件,才將此 goroutine 給 ready 啟用重新執行。顯然,在底層通知 goroutine 再次發生讀寫等事件的方式就是 epoll/kqueue/iocp 等事件驅動機制。
Go 是一門跨平臺的程式語言,而不同平臺針對特定的功能有不用的實現,這當然也包括了 I/O 多路複用技術,比如 Linux 裡的 I/O 多路複用有 select
、poll
和 epoll
,而 freeBSD 或者 MacOS 裡則是 kqueue
,而 Windows 裡則是基於非同步 I/O 實現的 iocp
,等等;因此,Go 為了實現底層 I/O 多路複用的跨平臺,分別基於上述的這些不同平臺的系統呼叫實現了多版本的 netpollers
資料結構
type netFD struct {
pfd poll.FD
// immutable until Close
family int
sotype int
isConnected bool // handshake completed or use of association with peer
net string
laddr Addr
raddr Addr
}
type FD struct {
// Lock sysfd and serialize access to Read and Write methods.
fdmu fdMutex
// System file descriptor. Immutable until Close.
Sysfd int
// I/O poller.
pd pollDesc
// Writev cache.
iovecs *[]syscall.Iovec
// Semaphore signaled when file is closed.
csema uint32
// Non-zero if this file has been set to blocking mode.
isBlocking uint32
// Whether this is a streaming descriptor, as opposed to a
// packet-based descriptor like a UDP socket. Immutable. IsStream bool
// Whether a zero byte read indicates EOF. This is false for a
// message based socket connection. ZeroReadIsEOF bool
// Whether this is a file rather than a network socket.
isFile bool
}
程式碼示例(服務端)
package main
import (
"fmt"
"log" "net")
func main() {
listen, err := net.Listen("tcp", ":8888")
if err != nil {
log.Println("listen error: ", err)
return
}
for {
conn, err := listen.Accept()
if err != nil {
log.Println("accept error: ", err)
break
}
// start a new goroutine to handle the new connection.
go HandleConn(conn)
}
}
func HandleConn(conn net.Conn) {
defer conn.Close()
packet := make([]byte, 1024)
for {
// block here if socket is not available for reading data.
n, err := conn.Read(packet)
if err != nil {
log.Println("read socket error: ", err)
return
}
fmt.Println(string(packet[:n]))
// same as above, block here if socket is not available for writing.
_, _ = conn.Write([]byte("hi"))
}
}
程式碼示例(客戶端)
package main
import (
"fmt"
"net"
"testing"
)
func TestClient(t *testing.T) {
conn, err := net.Dial("tcp", "127.0.0.1:8888")
if err != nil {
panic(err)
}
defer conn.Close()
packet := make([]byte, 1024)
d := "hello"
_, err = conn.Write([]byte(d))
if err != nil {
panic(err)
}
n, err := conn.Read(packet)
if err != nil {
panic(err)
}
fmt.Println(string(packet[:n]))
}
epoll對應方法
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
// Go 對上面三個呼叫的封裝
func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(block bool) gList
net.Listen
呼叫 net.Listen
之後,底層會通過 Linux 的系統呼叫 socket
方法建立一個 fd 分配給 listener,並用以來初始化 listener 的 netFD
,接著呼叫 netFD 的 listenStream
方法完成對 socket 的 bind&listen 操作以及對 netFD
的初始化
- 呼叫
epollcreate1
(netpollinit) 建立一個 epoll 例項epfd
,作為整個 runtime 的唯一 event-loop 使用; - 將
netpollBreakRd
通知訊號量封裝成epollevent
事件結構體註冊(netpollopen)進 epoll 例項。
Listener.Accept()
netpoll
accept socket 的工作流程如下:
- 服務端的 netFD 在
listen
時會建立 epoll 的例項,並將 listenerFD 加入 epoll 的事件佇列 - netFD 在
accept
時將返回的 connFD 也加入 epoll 的事件佇列 - netFD 在讀寫時出現
syscall.EAGAIN
錯誤,通過 pollDesc 的waitRead
方法將當前的 goroutine park 住,直到 ready,從 pollDesc 的waitRead
中返回
Conn.Read/Conn.Write
read/write 的工作流程如下:
- 在
accept
成功時會生成客戶端connFD,並將fd 加入 epoll 的事件佇列 - netFD 在read/write時出現
syscall.EAGAIN
錯誤,通過 pollDesc 的waitRead
方法將當前的 goroutine park 住,直到 ready,從 pollDesc 的waitRead
中返回
核心是呼叫 syscall.Read/syscall.Write 系統呼叫
epoll_wait時機
首先,client 連線 server 的時候,listener 通過 accept 呼叫接收新 connection,每一個新 connection 都啟動一個 goroutine 處理,accept 呼叫會把該 connection 的 fd 連帶所在的 goroutine 上下文資訊封裝註冊到 epoll 的監聽列表裡去,當 goroutine 呼叫
conn.Read
或者conn.Write
等需要阻塞等待的函式時,會被gopark
給封存起來並使之休眠,讓 P 去執行本地排程佇列裡的下一個可執行的 goroutine,往後 Go scheduler 會在迴圈排程的runtime.schedule()
函式以及 sysmon 監控執行緒中呼叫runtime.netpoll
以獲取可執行的 goroutine 列表並通過呼叫 injectglist 把剩下的 g 放入全域性排程佇列或者當前 P 本地排程佇列去重新執行。那麼當 I/O 事件發生之後,netpoller 是通過什麼方式喚醒那些在 I/O wait 的 goroutine 的?答案是通過
runtime.netpoll
。
runtime.netpoll
的核心邏輯是:
- 根據呼叫方的入參 delay,設定對應的呼叫
epollwait
的 timeout 值; - 呼叫
epollwait
等待發生了可讀/可寫事件的 fd; - 迴圈
epollwait
返回的事件列表,處理對應的事件型別, 組裝可執行的 goroutine 連結串列並返回。
本作品採用《CC 協議》,轉載必須註明作者和本文連結