Go語言TCP/IP網路程式設計
乍一看,通過TCP/IP層連線兩個程式會感覺可怕, 但是在Go語言中可能比你想象的要簡單的多。
TCP/IP層傳送資料的應用場景
當然很多情況下,不是大多數情況下,使用更高階別的網路協議毫無疑問會更好,因為可以使用華麗的API, 它們隱藏了很多技術細節。現在根據不同的需求,有很多選擇,比如訊息佇列協議, gRPC, protobuf, FlatBuffers, RESTful網站API, websocket等等。
然而在一些特殊的場景下,特別是小型專案,選擇任何其他方式都會感覺太臃腫了,更不用說你需要引入額外的依賴包了。
幸運的是,使用標準庫的net包來建立簡單的網路通訊不比你所見到的要困難。
因為Go語言中有下面兩點簡化。
簡化1: 連線就是io流
net.Conn介面實現了io.Reader, io.Writer和io.Closer介面。 因此可以像對待其他io流一樣對待TCP連線。
你可能會認為:”好,我能在TCP中傳送字串或位元組分片,非常不錯,但是遇到複雜的資料結構怎麼辦? 例如我們遇到的是結構體型別的資料?”
簡化2: Go語言知道如何有效的解碼複雜的型別
當說到通過網路傳送編碼的結構化資料,首先想到的就是JSON。 不過先稍等一下 – Go語言的標準庫encoding/gob包提供了一種序列化和發序列話Go資料型別的方法,它無需給結構體、Go語言不相容的JSON新增字串標籤, 或者等待使用json.Unmarshal來費勁的將文字解析為二進位制資料。
gob編碼解碼可以直接操作io流,這一點很完美的匹配第一條簡化。
下面我們就通過這兩條簡化規則一起實現一個簡單的App。
這個簡單APP的目標
這個app應該做兩件事情:
- 傳送和接收簡單的字串訊息。
- 通過gob傳送和接收結構體。
第一部分,傳送簡單字串,將演示無需藉助高階協議的情況下,通過TCP/IP網路傳送資料是多麼簡單。
第二部分,稍微深入一點,通過網路傳送完整的結構體,這些結構體使用字串、分片、對映、甚至包含到自身的遞迴指標。
辛虧有gob包,要做到這些不費吹灰之力。
客戶端 服務端
待傳送結構體 解碼後結構體
testStruct結構體 testStruct結構體
| ^
V |
gob編碼 ----------------------------> gob解碼
| ^
V |
傳送 ============網路================= 接收
通過TCP傳送字串資料的基本要素
傳送端上
傳送字串需要三個簡單的步驟:
- 開啟對應接收程式的連線。
- 寫字串。
- 關閉連線。
net包提供了一對實現這個功能的方法。
- ResolveTCPAddr(): 該函式返回TCP終端地址。
- DialTCP(): 類似於TCP網路的撥號。
這兩個方法都是在go原始碼的src/net/tcpsock.go檔案中定義的。
func ResolveTCPAddr(network, address string) (*TCPAddr, error) {
switch network {
case "tcp", "tcp4", "tcp6":
case "": // a hint wildcard for Go 1.0 undocumented behavior
network = "tcp"
default:
return nil, UnknownNetworkError(network)
}
addrs, err := DefaultResolver.internetAddrList(context.Background(), network, address)
if err != nil {
return nil, err
}
return addrs.forResolve(network, address).(*TCPAddr), nil
}
ResolveTCPAddr()接收兩個字串引數。
- network: 必須是TCP網路名,比如tcp, tcp4, tcp6。
- address: TCP地址字串,如果它不是字面量的IP地址或者埠號不是字面量的埠號, ResolveTCPAddr會將傳入的地址解決成TCP終端的地址。否則傳入一對字面量IP地址和埠數字作為地址。address引數可以使用host名稱,但是不推薦這樣做,因為它最多會返回host名字的一個IP地址。
ResolveTCPAddr()接收的代表TCP地址的字串(例如localhost:80, 127.0.0.1:80, 或[::1]:80, 都是代表本機的80埠), 返回(net.TCPAddr指標, nil)(如果字串不能被解析成有效的TCP地址會返回(nil, error))。
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error) {
switch network {
case "tcp", "tcp4", "tcp6":
default:
return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: UnknownNetworkError(network)}
}
if raddr == nil {
return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: nil, Err: errMissingAddress}
}
c, err := dialTCP(context.Background(), network, laddr, raddr)
if err != nil {
return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: err}
}
return c, nil
}
DialTCP()函式接收三個引數:
- network: 這個引數和ResolveTCPAddr的network引數一樣,必須是TCP網路名。
- laddr: TCPAddr型別的指標, 代表本地TCP地址。
- raddr: TCPAddr型別的指標,代表的是遠端TCP地址。
它會連線撥號兩個TCP地址,並返回這個連線作為net.TCPConn物件返回(連線失敗返回error)。如果我們不需要對Dial設定有過多控制,那麼我們就可以使用Dial()代替。
func Dial(network, address string) (Conn, error) {
var d Dialer
return d.Dial(network, address)
}
Dial()函式接收一個TCP地址,返回一個一般的net.Conn。 這已經足夠我們的測試用例了。然而如果你需要只有在TCP連線上的可用功能,可以使用TCP變體(DialTCP, TCPConn, TCPAddr等等)。
成功撥號之後,我們就可以如上所述的那樣,將新的連線與其他的輸入輸出流同等對待了。我們甚至可以將連線包裝進bufio.ReadWriter中,這樣可以使用各種ReadWriter方法,例如ReadString(), ReadBytes, WriteString等等。
func Open(addr string) (*bufio.ReadWriter, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, errors.Wrap(err, "Dialing "+addr+" failed")
}
// 將net.Conn物件包裝到bufio.ReadWriter中
return bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)), nil
}
記住緩衝Writer在寫之後需要呼叫Flush()方法, 這樣所有的資料才會刷到底層網路連線中。
最後,每個連線物件都有一個Close()方法來終止通訊。
微調(fine tuning)
Dialer結構體定義如下:
type Dialer struct {
Timeout time.Duration
Deadline time.Time
LocalAddr Addr
DualStack bool
FallbackDelay time.Duration
KeepAlive time.Duration
Resolver *Resolver
Cancel <-chan struct{}
}
- Timeout: 撥號等待連線結束的最大時間數。如果同時設定了Deadline, 可以更早失敗。預設沒有超時。 當使用TCP並使用多個IP地址撥號主機名,超時會在它們之間劃分。使用或不使用超時,作業系統都可以強迫更早超時。例如,TCP超時一般在3分鐘左右。
- Deadline: 是撥號即將失敗的絕對時間點。如果設定了Timeout, 可能會更早失敗。0值表示沒有截止期限, 或者依賴作業系統或使用Timeout選項。
- LocalAddr: 是撥號一個地址時使用的本地地址。這個地址必須是要撥號的network地址完全相容的型別。如果為nil, 會自動選擇一個本地地址。
- DualStack: 這個屬性可以啟用RFC 6555相容的”歡樂眼球(Happy Eyeballs)“撥號,當network是tcp時,address引數中的host可以被解析被IPv4和IPv6地址。這樣就允許客戶端容忍(tolerate)一個地址家族的網路規定稍微打破一下。
- FallbackDelay: 當DualStack啟用的時候, 指定在產生回退連線之前需要等待的時間。如果設定為0, 預設使用延時300ms。
- KeepAlive: 為活動網路連線指定保持活動的時間。如果設定為0,沒有啟用keep-alive。不支援keep-alive的網路協議會忽略掉這個欄位。
- Resolver: 可選項,指定使用的可替代resolver。
- Cancel: 可選通道,它的閉包表示撥號應該被取消。不是所有的撥號型別都支援撥號取消。 已廢棄,可使用DialContext代替。
有兩個可用選項可以微調。
因此Dialer介面提供了可以微調的兩方面選項:
- DeadLine和Timeout選項: 用於不成功撥號的超時設定。
- KeepAlive選項: 管理連線的使用壽命(life span)。
type Conn interface {
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
LocalAddr() Addr
RemoteAddr() Addr
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}
net.Conn介面是面向流的一般的網路連線。它具有下面這些介面方法:
- Read(): 從連線上讀取資料。
- Write(): 向連線上寫入資料。
- Close(): 關閉連線。
- LocalAddr(): 返回本地網路地址。
- RemoteAddr(): 返回遠端網路地址。
- SetDeadline(): 設定連線相關的讀寫最後期限。等價於同時呼叫SetReadDeadline()和SetWriteDeadline()。
- SetReadDeadline(): 設定將來的讀呼叫和當前阻塞的讀呼叫的超時最後期限。
- SetWriteDeadline(): 設定將來寫呼叫以及當前阻塞的寫呼叫的超時最後期限。
Conn介面也有deadline設定; 有對整個連線的(SetDeadLine()),也有特定讀寫呼叫的(SetReadDeadLine()和SetWriteDeadLine())。
注意deadline是(wallclock)時間固定點。和timeout不同,它們新活動之後不會重置。因此連線上的每個活動必須設定新的deadline。
下面的樣本程式碼沒有使用deadline, 因為它足夠簡單,我們可以很容易看到什麼時候會被卡住。Ctrl-C時我們手動觸發deadline的工具。
接收端上
接收端步驟如下:
- 對本地埠開啟監聽。
- 當請求到來時,產生(spawn)goroutine來處理請求。
- 在goroutine中,讀取資料。也可以選擇性的傳送響應。
- 關閉連線。
監聽需要指定本地監聽的埠號。一般來說,監聽應用程式(也叫server)宣佈監聽的埠號,如果提供標準服務, 那麼使用這個服務對應的相關埠。例如,web服務通常監聽80來伺服HTTP, 443埠伺服HTTPS請求。 SSH守護預設監聽22埠, WHOIS服務使用埠43。
type Listener interface {
// Accept waits for and returns the next connection to the listener.
Accept() (Conn, error)
// Close closes the listener.
// Any blocked Accept operations will be unblocked and return errors.
Close() error
// Addr returns the listener`s network address.
Addr() Addr
}
func Listen(network, address string) (Listener, error) {
addrs, err := DefaultResolver.resolveAddrList(context.Background(), "listen", network, address, nil)
if err != nil {
return nil, &OpError{Op: "listen", Net: network, Source: nil, Addr: nil, Err: err}
}
var l Listener
switch la := addrs.first(isIPv4).(type) {
case *TCPAddr:
l, err = ListenTCP(network, la)
case *UnixAddr:
l, err = ListenUnix(network, la)
default:
return nil, &OpError{Op: "listen", Net: network, Source: nil, Addr: la, Err: &AddrError{Err: "unexpected address type", Addr: address}}
}
if err != nil {
return nil, err // l is non-nil interface containing nil pointer
}
return l, nil
}
net包實現服務端的核心部分是:
net.Listen()在給定的本地網路地址上來建立新的監聽器。如果只傳埠號給它,例如”:61000″, 那麼監聽器會監聽所有可用的網路介面。 這相當方便,因為計算機通常至少提供兩個活動介面,迴環介面和最少一個真實網路卡。 這個函式成功的話返回Listener。
Listener介面有一個Accept()方法用來等待請求進來。然後它接受請求,並給呼叫者返回新的連線。Accept()一般來說都是在迴圈中呼叫,能夠同時服務多個連線。每個連線可以由一個單獨的goroutine處理,正如下面程式碼所示的。
程式碼部分
與其讓程式碼來回推送一些位元組,我更想要它演示一些更有用的東西。 我想讓它能給伺服器傳送帶有不同資料載體的不同命令。伺服器應該能標識每個命令和解碼命令資料。
我們程式碼中客戶端會傳送兩種型別的命令: “STRING”和”GOB”。它們都以換行符終止。
“STRING”命令包含一行字串資料,可以通過bufio中的簡單讀寫操作來處理。
“GOB”命令由結構體組成,這個結構體包含一些欄位,包含一個分片和對映,甚至指向自己的指標。 正如你所見,當執行這個程式碼時,gob包能通過我們的網路連線移動這些資料沒有什麼稀奇(fuss).
我們這裡基本上都是一些即席協議(ad-hoc protocol: 特設的、特定目的的、即席的、專案的), 客戶端和服務端都遵循它,命令列後面是換行,然後是資料。對於每個命令來說,服務端必須知道資料的確切格式,知道如何處理它。
要達到這個目的,服務端程式碼採取兩步方式實現。
- 第一步: 當Listen()函式接收到新連線,它會產生一個新的goroutine來呼叫handleMessage()。 這個函式從連線中讀取命令名, 從對映中查詢合適的處理器函式,然後呼叫它。
- 第二步: 選擇的處理器函式讀取並處理命令列的資料。
package main
import (
"bufio"
"encoding/gob"
"flag"
"github.com/pkg/errors"
"io"
"log"
"net"
"strconv"
"strings"
"sync"
)
type complexData struct {
N int
S string
M map[string]int
P []byte
C *complexData
}
const (
Port = ":61000"
)
Outcoing connections(發射連線)
使用發射連線是一種快照。net.Conn滿足io.Reader和io.Writer介面,因此我們可以將TCP連線和其他任何的Reader和Writer一樣看待。
func Open(addr string) (*bufio.ReadWriter, error) {
log.Println("Dial " + addr)
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, errors.Wrap(err, "Dialing " + addr + " failed")
}
return bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)), nil
}
開啟TCP地址的連線。它返回一個帶有超時的TCP連線,並將其包裝進緩衝的ReadWriter。撥號遠端程式。注意本地埠是實時(on the fly)分配的。如果必須指定本地埠號,請使用DialTCP()方法。
進入連線
這節有點涉及到對進入資料的準備環節處理。根據我們前面介紹的ad-hoc協議,命令名+換行符+資料+換行符。自然資料是和具體命令相關的。要處理這樣的情況,我們建立了一個Endpoint物件,它具有下面的屬性:
- 它允許註冊一個或多個處理器函式,每個函式可以處理一個特殊的命令。
- 它根據命令名將具體命令排程到相關的處理器函式。
首先我們宣告一個HandleFunc型別,該型別為接收一個bufio.ReadWriter指標值的函式型別, 也就是後面我們要為每種不同命令註冊的處理器函式。它接收的引數是使用ReadWriter介面包裝的net.Conn連線。
type HandleFunc func(*bufio.ReadWriter)
然後我們宣告一個Endpoint結構體型別,它有三個屬性:
- listener: net.Listen()返回的Listener物件。
- handler: 用於儲存已註冊的處理器函式的對映。
- m: 一個互斥鎖,用於解決map的多goroutine不安全的問題。
type Endpoint struct {
listener net.Listener
handler map[string]HandleFunc
m sync.RWMutex // Maps不是執行緒安全的,因此需要互斥鎖來控制訪問。
}
func NewEndpoint() *Endpoint {
return &Endpoint{
handler: map[string]HandleFunc{},
}
}
func (e *Endpoint) AddHandleFunc(name string, f HandleFunc) {
e.m.Lock()
e.handler[name] = f
e.m.Unlock()
}
func (e *Endpoint) Listen() error {
var err error
e.listener, err = net.Listen("tcp", Port)
if err != nil {
return errors.Wrap(err, "Unable to listen on "+e.listener.Addr().String()+"
")
}
log.Println("Listen on", e.listener.Addr().String())
for {
log.Println("Accept a connection request.")
conn, err := e.listener.Accept()
if err != nil {
log.Println("Failed accepting a connection request:", err)
continue
}
log.Println("Handle incoming messages.")
go e.handleMessages(conn)
}
}
// handleMessages讀取連線到第一個換行符。 基於這個字串,它會呼叫恰當的HandleFunc。
func (e *Endpoint) handleMessages(conn net.Conn) {
// 將連線包裝到緩衝reader以便於讀取
rw := bufio.NewReadWrite(bufio.NewReader(conn), bufio.NewWriter(conn))
defer conn.Close()
// 從連線讀取直到遇到EOF. 期望下一次輸入是命令名。呼叫註冊的用於該命令的處理器。
for {
log.Print("Receive command `")
cmd, err := rw.ReadString(`
`)
switch {
case err == io.EOF:
log.Println("Reached EOF - close this connection.
---")
return
case err != nil:
log.Println("
Error reading command. Got: `" + cmd + "`
", err)
}
// 修剪請求字串中的多餘回車和空格- ReadString不會去掉任何換行。
cmd = strings.Trim(cmd, "
")
log.Println(cmd + "`")
// 從handler對映中獲取恰當的處理器函式, 並呼叫它。
e.m.Lock()
handleCommand, ok := e.handler[cmd]
e.m.Unlock()
if !ok {
log.Println("Command `" + cmd + "` is not registered.")
return
}
handleCommand(rw)
}
}
NewEndpoint()函式是Endpoint的工廠函式。它只對handler對映進行了初始化。為了簡化問題,假設我們的終端監聽的埠好是固定的。
Endpoint型別宣告瞭幾個方法:
- AddHandleFunc(): 使用互斥鎖為handler屬性安全新增處理特定型別命令的處理器函式。
- Listen(): 對終端埠的所有介面啟動監聽。 在呼叫Listen之前,至少要通過AddHandleFunc()註冊一個handler函式。
- HandleMessages(): 將連線用bufio包裝起來,然後分兩步讀取,首先讀取命令加換行,我們得到命令名字。 然後通過handler獲取註冊的該命令對應的處理器函式, 然後排程這個函式來執行資料讀取和解析。
注意上面如何使用動態函式的。 根據命令名查詢具體函式,然後這個具體函式賦值給handleCommand, 其實這個變數型別為HandleFunc型別, 即前面宣告的處理器函式型別。
Endpoint的Listen方法呼叫之前需要先至少註冊一個處理器函式。因此我們下面定義兩個型別的處理器函式: handleStrings和handleGob。
handleStrings()函式接收和處理我們即時協議中只傳送字串資料的處理器函式。handleGob()函式是接收並處理髮送的gob資料的複雜結構體。handleGob稍微複雜一點,除了讀取資料外,我們海需要解碼資料。
我們可以看到連續兩次使用rw.ReadString(`n`), 讀取字串,遇到換行停止, 將讀到的內容儲存到字串中。注意這個字串是包含末尾換行的。
另外對於普通字串資料來說,我們直接用bufio包裝連線後的ReadString來讀取。而對於複雜的gob結構體來說,我們使用gob來解碼資料。
func handleStrings(rw *bufio.ReadWriter) {
log.Print("Receive STRING message:")
s, err := rw.ReadString(`
`)
if err != nil {
log.Println("Cannot read from connection.
", err)
}
s = strings.Trim(s, "
")
log.Println(s)
-, err = rw.WriteString("Thank you.
")
if err != nil {
log.Println("Cannot write to connection.
", err)
}
err = rw.Flush()
if err != nil {
log.Println("Flush failed.", err)
}
}
func handleGob(rw *bufio.ReadWriter) {
log.Print("Receive GOB data:")
var data complexData
dec := gob.NewDecoder(rw)
err := dec.Decode(&data)
if err != nil {
log.Println("Error decoding GOB data:", err)
return
}
log.Printf("Outer complexData struct:
%#v
", data)
log.Printf("Inner complexData struct:
%#v
", data.C)
}
客戶端和服務端函式
一切就緒,我們可以準備我們的客戶端和服務端函式了。
- 客戶端函式連線到伺服器併傳送STRING和GOB請求。
- 服務端開始監聽請求並觸發恰當的處理器。
// 當應用程式使用-connect=ip地址的時候被呼叫
func client(ip string) error {
testStruct := complexData{
N: 23,
S: "string data",
M: map[string]int{"one": 1, "two": 2, "three": 3},
P: []byte("abc"),
C: &complexData{
N: 256,
S: "Recursive structs? Piece of cake!",
M: Map[string]int{"01": "10": 2, "11": 3},
},
}
rw, err := Open(ip + Port)
if err != nil {
return errors.Wrap(err, "Client: Failed to open connection to " + ip + Port)
}
log.Println("Send the string request.")
n, err := rw.WriteString("STRING
")
if err != nil {
return errors.Wrap(err, "Could not send the STRING request (" + strconv.Itoa(n) + " bytes written)")
}
// 傳送STRING請求。傳送請求名併傳送資料。
log.Println("Send the string request.")
n, err = rw.WriteString("Additional data.
")
if err != nil {
return errors.Wrap(err, "Could not send additional STRING data (" + strconv.Itoa(n) + " bytes written)")
}
log.Println("Flush the buffer.")
err = rw.Flush()
if err != nil {
return errors.Wrap(err, "Flush failed.")
}
// 讀取響應
log.Println("Read the reply.")
response, err := rw.ReadString(`
`)
if err != nil {
return errors.Wrap(err, "Client: Failed to read the reply: `" + response + "`")
}
log.Println("STRING request: got a response:", response)
// 傳送GOB請求。 建立一個encoder直接將它轉換為rw.Send的請求名。傳送GOB
log.Println("Send a struct as GOB:")
log.Printf("Outer complexData struct:
%#v
", testStruct)
log.Printf("Inner complexData struct:
%#v
", testStruct.C)
enc := gob.NewDecoder(rw)
n, err = rw.WriteString("GOB
")
if err != nil {
return errors.Wrap(err, "Could not write GOB data (" + strconv.Itoa(n) + " bytes written)")
}
err = enc.Encode(testStruct)
if err != nil {
return errors.Wrap(err, "Encode failed for struct: %#v", testStruct)
}
err = rw.Flush()
if err != nil {
return errors.Wrap(err, "Flush failed.")
}
return nil
}
客戶端函式在執行應用程式時指定connect標誌的時候執行,這點後面的程式碼可以看到。
下面是服務端程式server。服務端監聽進來的請求並根據請求命令名將它們排程給註冊的具體相關處理器。
func server() error {
endpoint := NewEndpoint()
// 新增處理器函式
endpoint.AddHandleFunc("STRING", handleStrings)
endpoint.AddHandleFunc("GOB", handleGOB)
// 開始監聽
return endpoint.Listen()
}
main函式
下面的main函式既可以啟動客戶端也可以啟動服務端, 依賴於是否設定connect標誌。 如果沒有這個標誌,則以伺服器啟動程式, 監聽進來的請求。如果有標誌, 啟動為客戶端,並連線到這個標誌指定的主機。
可以使用localhost或127.0.0.1在同一機器上執行這兩個程式。
func main() {
connect := flag.String("connect", "", "IP address of process to join. If empty, go into the listen mode.")
flag.Parse()
// 如果設定了connect標誌,進入客戶端模式
if *connect != `` {
err := client(*connect)
if err != nil {
log.Println("Error:", errors.WithStack(err))
}
log.Println("Client done.")
return
}
// 否則進入服務端模式
err := server()
if err != nil {
log.Println("Error:", errors.WithStack(err))
}
log.Println("Server done.")
}
// 設定日誌記錄的欄位標誌
func init() {
log.SetFlags(log.Lshortfile)
}
如何獲取並執行程式碼
第一步: 獲取程式碼。 注意-d標誌自動安裝二進位制到$GOPATH/bin目錄。
go get -d github.com/appliedgo/networking
第二步: cd到原始碼目錄。
cd $GOPATH/src/github.com/appliedgo/networking
第三步: 執行服務端。
go run networking.go
第四步: 開啟另外一個shell, 同樣進入到原始碼目錄(第二步), 然後執行客戶端。
go run networking.go -connect localhost
Tips
如果你想稍微修改下原始碼,下面是一些建議:
- 在不同機器執行客戶端和服務端(同一個區域網中).
- 用更多的對映和指標來增強(beef up)complexData, 看看gob如何應對它(cope with it)。
- 同時啟動多個客戶端,看看服務端是否能處理它們。
2017-02-09: map不是執行緒安全的,因此如果在不同的goroutine中使用同一個map, 應該使用互斥鎖來控制map的訪問。
而上面的程式碼,map在goroutine啟動之前已經新增好了, 因此你可以安全的修改程式碼,在handleMessages goroutine已經執行的時候呼叫AddHandleFunc()。
本章所學知識點總結
—- 2018-05-04 —–
- bufio的應用。
- gob的應用。
- map在多goroutine間共享時是不安全的。