cosocket 是各種 lua-resty-* 非阻塞庫的基礎,沒 有 cosocket,開發者就無法用 Lua 來快速連接各種外部的網絡服務。
在早期的 OpenResty 版本中,如果想要去與 Redis、memcached 這些服務交互的話,需要使用 redis2-nginx-module、redis-nginx-module 和 memc-nginx-module這些 C 模塊.這些模塊至今仍然在 OpenResty 的發行包中。
cosocket 功能加入以后,它們都已經被 lua-resty-redis 和 lua-resty-memcached 替代,基 本上沒人再去使用 C 模塊連接外部服務了。
什么是 cosocket
cosocket是 OpenResty 中的專有名詞,是把協程和網絡套接字的英文 拼在一起形成的,即 cosocket = coroutine + socket。所以,可以把 cosocket 翻譯為“協程套接字”。
cosocket 不僅需要 Lua 協程特性的支持,也需要 Nginx 中非常重要的事件機制的支持,這兩者結合在一 起,最終實現了非阻塞網絡 I/O。另外,cosocket 支持 TCP、UDP 和 Unix Domain Socket。
在 OpenResty 中調用一個 cosocket 相關函數,內部實現便是下面這張圖的樣子:
用戶的 Lua 腳本每觸發一個網絡操作,都會有協程的 yield 以及 resume。
遇到網絡 I/O 時,它會交出控制權(yield),把網絡事件注冊到 Nginx 監聽列表中,並把權限交給 Nginx;當有 Nginx 事件達到觸發條件時,便喚醒對應的協程繼續處理(resume)。
OpenResty 正是以此為基礎,封裝實現 connect、send、receive 等操作,形成了現在的 cosocket API。以處理 TCP 的 API 為例來介紹一下。處理 UDP 和 Unix Domain Socket ,與TCP 的接口基 本是一樣的。
cosocket API 和指令簡介
TCP 相關的 cosocket API 可以分為下面這幾類:
- 創建對象:ngx.socket.tcp。
- 設置超時:tcpsock:settimeout 和 tcpsock:settimeouts。
- 建立連接:tcpsock:connect。
- 發送數據:tcpsock:send。
- 接受數據:tcpsock:receive、tcpsock:receiveany 和 tcpsock:receiveuntil。
- 連接池:tcpsock:setkeepalive。
- 關閉連接:tcpsock:close。
這些 API 可以使用的上下文:
rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_
由於 Nginx 內核的各種限制,cosocket API 在 set_by_lua*, log_by_lua*, header_filter_by_lua* 和 body_filter_by_lua* 中是無法使用的。而在 init_by_lua* 和 init_worker_by_lua* 中暫時也不能用,不過 Nginx 內核對這兩個階段並沒有限制。
此外,與這些 API 相關的,還有 8 個 lua_socket_ 開頭的 Nginx 指令:
- lua_socket_connect_timeout:連接超時,默認 60 秒。
- lua_socket_send_timeout:發送超時,默認 60 秒。
- lua_socket_send_lowat:發送閾值(low water),默認為 0。
- lua_socket_read_timeout: 讀取超時,默認 60 秒。
- lua_socket_buffer_size:讀取數據的緩存區大小,默認 4k/8k。
- lua_socket_pool_size:連接池大小,默認 30。
- lua_socket_keepalive_timeout:連接池 cosocket 對象的空閑時間,默認 60 秒。
- lua_socket_log_errors:cosocket 發生錯誤時,是否記錄日志,默認為 on。
有些指令和 API 的功能一樣的,比如設置超時時間和連接池大小等。不過,如果兩者有沖突的話,API 的優先級高於指令,會覆蓋指令設置的值。所以,一般來說,都推薦使用 API來做設 置,這樣也會更加靈活。
通過一個具體的例子,來理解如何使用這些 cosocket API。發送 TCP 請求到一個網站,並把返回的內容打印出來:
resty -e 'local sock = ngx.socket.tcp() sock:settimeout(1000) -- one second timeout local ok, err = sock:connect("www.baidu.com", 80) if not ok then ngx.say("failed to connect: ", err) return end local req_data = "GET / HTTP/1.1\r\nHost: www.baidu.com\r\n\r\n" local bytes, err = sock:send(req_data) if err then ngx.say("failed to send: ", err) return end local data, err, partial = sock:receive() if err then ngx.say("failed to receive: ", err) return end sock:close() ngx.say("response is: ", data) '
分析下這段代碼:
- 首先,通過 ngx.socket.tcp() ,創建 TCP 的 cosocket 對象,名字是 sock。
- 然后,使用 settimeout() ,把超時時間設置為 1 秒。注這里的超時沒有區分 connect、receive,是統一的設置。
- 接着,使用 connect() 去連接指定網站的 80 端口,如果失敗就直接退出。
- 連接成功的話,就使用 send() 來發送構造好的數據,如果發送失敗就退出。
- 發送數據成功的話,就使用 receive() 來接收網站返回的數據。這里 receive() 的默認參數值是 *l,也就是只返回第一行的數據;如果參數設置為了*a,就是持續接收數據,直到連接關閉;
- 最后,調用 close() ,主動關閉 socket 連接。
接 下來,我們對這個示例再做一些調整:
第一個動作,對 socket 連接、發送和讀取這三個動作,分別設置超時時間。
settimeout() 作用是把超時時間統一設置為一個值。如果要想分開設置,就需要使用 settimeouts() 函數,比如下面這樣的寫法:
sock:settimeouts(1000, 2000, 3000)
表示連接超時為 1 秒,發送超時為 2 秒,讀取超時為 3 秒。在OpenResty 和 lua-resty 庫中,大部分和時間相關的 API 的參數,都以毫秒為單位
第二個動作,receive接收指定大小的內容。
receive() 接口可以接收一行數據,也可以持續接收數據。如果只想接收 10K 大小的數據,應該使用receiveany() ,它就是專為滿足這種需求而設計的
local data, err, partial = sock:receiveany(10240)
關於receive,還有另一個很常見的用戶需求,那就是一直獲取數據,直到遇到指定字符串才停止。
receiveuntil() 專門用來解決這類問題,它不會像 receive() 和 receiveany() 一樣返回字符串, 而會返回一個迭代器。這樣就可以在循環中調用它來分段讀取匹配到的數據,當讀取完畢時,就會返回 nil。
local reader = sock:receiveuntil("\r\n") while true do local data, err, partial = reader(4) if not data then if err then ngx.say("failed to read the data stream: ", err) break end ngx.say("read done") break end ngx.say("read chunk: [", data, "]") end
receiveuntil 會返回 \r\n 之前的數據,並通過迭代器每次讀取其中的 4 個字節,
第三個動作,不直接關閉 socket,而是放入連接池中。
沒有連接池的話,每次請求進來都要新建一個連接,就會導致 cosocket 對象被頻繁地創建和銷 毀,造成不必要的性能損耗。
為了避免這個問題,在使用完一個 cosocket 后,可以調用 setkeepalive() 放到連接池中
local ok, err = sock:setkeepalive(2 * 1000, 100) if not ok then ngx.say("failed to set reusable: ", err) end
這段代碼設置了連接的空閑時間為 2 秒,連接池的大小為 100。這樣,在調用 connect() 函數時,就會優先從連接池中獲取 cosocket 對象。
關於連接池的使用,有兩點需要注意:
第一,不能把發生錯誤的連接放入連接池,否則下次使用時,就會導致收發數據失敗。這也是為什么需要判斷每一個 API 調用是否成功的一個原因。
第二,要搞清楚連接的數量。連接池是 worker 級別的,每個 worker 都有自己的連接池。所以,如果有 10 個 worker,連接池大小設置為 30,那么對於后端的服務來講,就等於有 300個連接。