陳碩 (giantchen AT gmail)
blog.csdn.net/Solstice
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文以一個(gè) Sudoku Solver 為例,回顧了并發(fā)網(wǎng)絡(luò)服務(wù)程序的多種設(shè)計(jì)方案,并介紹了使用 muduo 網(wǎng)絡(luò)庫編寫多線程服務(wù)器的兩種最常用手法。以往的例子展現(xiàn)了 Muduo 在編寫單線程并發(fā)網(wǎng)絡(luò)服務(wù)程序方面的能力與便捷性,今天我們看一看它在多線程方面的表現(xiàn)。
本文代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/
Sudoku Solver
假設(shè)有這么一個(gè)網(wǎng)絡(luò)編程任務(wù):寫一個(gè)求解數(shù)獨(dú)的程序 (Sudoku Solver),并把它做成一個(gè)網(wǎng)絡(luò)服務(wù)。
Sudoku Solver 是我喜愛的網(wǎng)絡(luò)編程例子,它曾經(jīng)出現(xiàn)在《分布式系統(tǒng)部署、監(jiān)控與進(jìn)程管理的幾重境界》、《Muduo 設(shè)計(jì)與實(shí)現(xiàn)之一:Buffer 類的設(shè)計(jì)》、《〈多線程服務(wù)器的適用場合〉例釋與答疑》等文中,它也可以看成是 echo 服務(wù)的一個(gè)變種(《談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)》把 echo 列為三大 TCP 網(wǎng)絡(luò)編程案例之一)。
寫這么一個(gè)程序在網(wǎng)絡(luò)編程方面的難度不高,跟寫 echo 服務(wù)差不多(從網(wǎng)絡(luò)連接讀入一個(gè) Sudoku 題目,算出答案,再發(fā)回給客戶),挑戰(zhàn)在于怎樣做才能發(fā)揮現(xiàn)在多核硬件的能力?在談這個(gè)問題之前,讓我們先寫一個(gè)基本的單線程版。
協(xié)議
一個(gè)簡單的以 \r\n 分隔的文本行協(xié)議,使用 TCP 長連接,客戶端在不需要服務(wù)時(shí)主動斷開連接。
請求:[id:]〈81digits〉\r\n
響應(yīng):[id:]〈81digits〉\r\n 或者 [id:]NoSolution\r\n
其中[id:]表示可選的 id,用于區(qū)分先后的請求,以支持 Parallel Pipelining,響應(yīng)中會回顯請求中的 id。Parallel Pipelining 的意義見賴勇浩的《以小見大——那些基于 protobuf 的五花八門的 RPC(2) 》,或者見我寫的《分布式系統(tǒng)的工程化開發(fā)方法》第 54 頁關(guān)于 out-of-order RPC 的介紹。
〈81digits〉是 Sudoku 的棋盤,9x9 個(gè)數(shù)字,未知數(shù)字以 0 表示。
如果 Sudoku 有解,那么響應(yīng)是填滿數(shù)字的棋盤;如果無解,則返回 NoSolution。
例子1:
請求:000000010400000000020000000000050407008000300001090000300400200050100000000806000\r\n
響應(yīng):693784512487512936125963874932651487568247391741398625319475268856129743274836159\r\n
例子2:
請求:a:000000010400000000020000000000050407008000300001090000300400200050100000000806000\r\n
響應(yīng):a:693784512487512936125963874932651487568247391741398625319475268856129743274836159\r\n
例子3:
請求:b:000000010400000000020000000000050407008000300001090000300400200050100000000806005\r\n
響應(yīng):b:NoSolution\r\n
基于這個(gè)文本協(xié)議,我們可以用 telnet 模擬客戶端來測試 sudoku solver,不需要單獨(dú)編寫 sudoku client。SudokuSolver 的默認(rèn)端口號是 9981,因?yàn)樗?9x9=81 個(gè)格子。
基本實(shí)現(xiàn)
Sudoku 的求解算法見《談?wù)剶?shù)獨(dú)(Sudoku)》一文,這不是本文的重點(diǎn)。假設(shè)我們已經(jīng)有一個(gè)函數(shù)能求解 Sudoku,它的原型如下
string solveSudoku(const string& puzzle);
函數(shù)的輸入是上文的"〈81digits〉",輸出是"〈81digits〉"或"NoSolution"。這個(gè)函數(shù)是個(gè) pure function,同時(shí)也是線程安全的。
有了這個(gè)函數(shù),我們以《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中的 EchoServer 為藍(lán)本,稍作修改就能得到 SudokuServer。這里只列出最關(guān)鍵的 onMessage() 函數(shù),完整的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_basic.cc 。onMessage() 的主要功能是處理協(xié)議格式,并調(diào)用 solveSudoku() 求解問題。
// muduo/examples/sudoku/server_basic.cc
const int kCells = 81;
void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp)
{
LOG_DEBUG << conn->name();
size_t len = buf->readableBytes();
while (len >= kCells + 2)
{
const char* crlf = buf->findCRLF();
if (crlf)
{
string request(buf->peek(), crlf);
string id;
buf->retrieveUntil(crlf + 2);
string::iterator colon = find(request.begin(), request.end(), ':');
if (colon != request.end())
{
id.assign(request.begin(), colon);
request.erase(request.begin(), colon+1);
}
if (request.size() == implicit_cast<size_t>(kCells))
{
string result = solveSudoku(request);
if (id.empty())
{
conn->send(result+"\r\n");
}
else
{
conn->send(id+":"+result+"\r\n");
}
}
else
{
conn->send("Bad Request!\r\n");
conn->shutdown();
}
}
else
{
break;
}
}
}
server_basic.cc 是一個(gè)并發(fā)服務(wù)器,可以同時(shí)服務(wù)多個(gè)客戶連接。但是它是單線程的,無法發(fā)揮多核硬件的能力。
Sudoku 是一個(gè)計(jì)算密集型的任務(wù)(見《Muduo 設(shè)計(jì)與實(shí)現(xiàn)之一:Buffer 類的設(shè)計(jì)》中關(guān)于其性能的分析),其瓶頸在 CPU。為了讓這個(gè)單線程 server_basic 程序充分利用 CPU 資源,一個(gè)簡單的辦法是在同一臺機(jī)器上部署多個(gè) server_basic 進(jìn)程,讓每個(gè)進(jìn)程占用不同的端口,比如在一臺 8 核機(jī)器上部署 8 個(gè) server_basic 進(jìn)程,分別占用 9981、9982、……、9988 端口。這樣做其實(shí)是把難題推給了客戶端,因?yàn)榭蛻舳?s)要自己做負(fù)載均衡。再想得遠(yuǎn)一點(diǎn),在 8 個(gè) server_basic 前面部署一個(gè) load balancer?似乎小題大做了。
能不能在一個(gè)端口上提供服務(wù),并且又能發(fā)揮多核處理器的計(jì)算能力呢?當(dāng)然可以,辦法不止一種。
常見的并發(fā)網(wǎng)絡(luò)服務(wù)程序設(shè)計(jì)方案
W. Richard Stevens 的 UNP2e 第 27 章 Client-Server Design Alternatives 介紹了十來種當(dāng)時(shí)(90 年代末)流行的編寫并發(fā)網(wǎng)絡(luò)程序的方案。UNP3e 第 30 章,內(nèi)容未變,還是這幾種。以下簡稱 UNP CSDA 方案。UNP 這本書主要講解阻塞式網(wǎng)絡(luò)編程,在非阻塞方面著墨不多,僅有一章。正確使用 non-blocking IO 需要考慮的問題很多,不適宜直接調(diào)用 Sockets API,而需要一個(gè)功能完善的網(wǎng)絡(luò)庫支撐。
隨著 2000 年前后第一次互聯(lián)網(wǎng)浪潮的興起,業(yè)界對高并發(fā) http 服務(wù)器的強(qiáng)烈需求大大推動了這一領(lǐng)域的研究,目前高性能 httpd 普遍采用的是單線程 reactor 方式。另外一個(gè)說法是 IBM Lotus 使用 TCP 長連接協(xié)議,而把 Lotus 服務(wù)端移植到 Linux 的過程中 IBM 的工程師們大大提高了 Linux 內(nèi)核在處理并發(fā)連接方面的可伸縮性,因?yàn)橐粋€(gè)公司可能有上萬人同時(shí)上線,連接到同一臺跑著 Lotus server 的 Linux 服務(wù)器。
可伸縮網(wǎng)絡(luò)編程這個(gè)領(lǐng)域其實(shí)近十年來沒什么新東西,POSA2 已經(jīng)作了相當(dāng)全面的總結(jié),另外以下幾篇文章也值得參考。
http://bulk.fefe.de/scalable-networking.pdf
http://www.kegel.com/c10k.html
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
下表是陳碩總結(jié)的 10 種常見方案。其中“多連接互通”指的是如果開發(fā) chat 服務(wù),多個(gè)客戶連接之間是否能方便地交換數(shù)據(jù)(chat 也是《談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)》中舉的三大 TCP 網(wǎng)絡(luò)編程案例之一)。對于 echo/http/sudoku 這類“連接相互獨(dú)立”的服務(wù)程序,這個(gè)功能無足輕重,但是對于 chat 類服務(wù)至關(guān)重要。“順序性”指的是在 http/sudoku 這類請求-響應(yīng)服務(wù)中,如果客戶連接順序發(fā)送多個(gè)請求,那么計(jì)算得到的多個(gè)響應(yīng)是否按相同的順序發(fā)還給客戶(這里指的是在自然條件下,不含刻意同步)。
UNP CSDA 方案歸入 0~5。5 也是目前用得很多的單線程 reactor 方案,muduo 對此提供了很好的支持。6 和 7 其實(shí)不是實(shí)用的方案,只是作為過渡品。8 和 9 是本文重點(diǎn)介紹的方案,其實(shí)這兩個(gè)方案已經(jīng)在《多線程服務(wù)器的常用編程模型》一文中提到過,只不過當(dāng)時(shí)我還沒有寫 muduo,無法用具體的代碼示例來說明。
在對比各方案之前,我們先看看基本的 micro benchmark 數(shù)據(jù)(前三項(xiàng)由 lmbench 測得):
- fork()+exit(): 160us
- pthread_create()+pthread_join(): 12us
- context switch : 1.5us
- sudoku resolve: 100us (根據(jù)題目難度不同,浮動范圍 20~200us)
方案 0:這其實(shí)不是并發(fā)服務(wù)器,而是 iterative 服務(wù)器,因?yàn)樗淮沃荒芊?wù)一個(gè)客戶。代碼見 UNP figure 1.9,UNP 以此為對比其他方案的基準(zhǔn)點(diǎn)。這個(gè)方案不適合長連接,到是很適合 daytime 這種 write-only 服務(wù)。
方案 1:這是傳統(tǒng)的 Unix 并發(fā)網(wǎng)絡(luò)編程方案,UNP 稱之為 child-per-client 或 fork()-per-client,另外也俗稱 process-per-connection。這種方案適合并發(fā)連接數(shù)不大的情況。至今仍有一些網(wǎng)絡(luò)服務(wù)程序用這種方式實(shí)現(xiàn),比如 PostgreSQL 和 Perforce 的服務(wù)端。這種方案適合“計(jì)算響應(yīng)的工作量遠(yuǎn)大于 fork() 的開銷”這種情況,比如數(shù)據(jù)庫服務(wù)器。這種方案適合長連接,但不太適合短連接,因?yàn)?fork() 開銷大于求解 sudoku 的用時(shí)。
方案 2:這是傳統(tǒng)的 Java 網(wǎng)絡(luò)編程方案 thread-per-connection,在 Java 1.4 引入 NIO 之前,Java 網(wǎng)絡(luò)服務(wù)程序多采用這種方案。它的初始化開銷比方案 1 要小很多。這種方案的伸縮性受到線程數(shù)的限制,一兩百個(gè)還行,幾千個(gè)的話對操作系統(tǒng)的 scheduler 恐怕是個(gè)不小的負(fù)擔(dān)。
方案 3:這是針對方案 1 的優(yōu)化,UNP 詳細(xì)分析了幾種變化,包括對 accept 驚群問題的考慮。
方案 4:這是對方案 2 的優(yōu)化,UNP 詳細(xì)分析了它的幾種變化。
以上幾種方案都是阻塞式網(wǎng)絡(luò)編程,程序(thread-of-control)通常阻塞在 read() 上,等待數(shù)據(jù)到達(dá)。但是 TCP 是個(gè)全雙工協(xié)議,同時(shí)支持 read() 和 write() 操作,當(dāng)一個(gè)線程/進(jìn)程阻塞在 read() 上,但程序又想給這個(gè) TCP 連接發(fā)數(shù)據(jù),那該怎么辦?比如說 echo client,既要從 stdin 讀,又要從網(wǎng)絡(luò)讀,當(dāng)程序正在阻塞地讀網(wǎng)絡(luò)的時(shí)候,如何處理鍵盤輸入?又比如 proxy,既要把連接 a 收到的數(shù)據(jù)發(fā)給連接 b,又要把從連接 b 收到的數(shù)據(jù)發(fā)給連接 a,那么到底讀哪個(gè)?(proxy 是《談一談網(wǎng)絡(luò)編程學(xué)習(xí)經(jīng)驗(yàn)》中舉的三大 TCP 網(wǎng)絡(luò)編程案例之一。)
一種方法是用兩個(gè)線程/進(jìn)程,一個(gè)負(fù)責(zé)讀,一個(gè)負(fù)責(zé)寫。UNP 也在實(shí)現(xiàn) echo client 時(shí)介紹了這種方案。另外見 Python Pinhole 的代碼:http://code.activestate.com/recipes/114642/
另一種方法是使用 IO multiplexing,也就是 select/poll/epoll/kqueue 這一系列的“多路選擇器”,讓一個(gè) thread-of-control 能處理多個(gè)連接。“IO 復(fù)用”其實(shí)復(fù)用的不是 IO 連接,而是復(fù)用線程。使用 select/poll 幾乎肯定要配合 non-blocking IO,而使用 non-blocking IO 肯定要使用應(yīng)用層 buffer,原因見《Muduo 設(shè)計(jì)與實(shí)現(xiàn)之一:Buffer 類的設(shè)計(jì)》。這就不是一件輕松的事兒了,如果每個(gè)程序都去搞一套自己的 IO multiplexing 機(jī)制(本質(zhì)是 event-driven 事件驅(qū)動),這是一種很大的浪費(fèi)。感謝 Doug Schmidt 為我們總結(jié)出了 Reactor 模式,讓 event-driven 網(wǎng)絡(luò)編程有章可循。繼而出現(xiàn)了一些通用的 reactor 框架/庫,比如 libevent、muduo、Netty、twisted、POE 等等,有了這些庫,我想基本不用去編寫阻塞式的網(wǎng)絡(luò)程序了(特殊情況除外,比如 proxy 流量限制)。
單線程 reactor 的程序結(jié)構(gòu)是(圖片取自 Doug Lea 的演講):

方案 5:基本的單線程 reactor 方案,即前面的 server_basic.cc 程序。本文以它作為對比其他方案的基準(zhǔn)點(diǎn)。這種方案的優(yōu)點(diǎn)是由網(wǎng)絡(luò)庫搞定數(shù)據(jù)收發(fā),程序只關(guān)心業(yè)務(wù)邏輯;缺點(diǎn)在前面已經(jīng)談了:適合 IO 密集的應(yīng)用,不太適合 CPU 密集的應(yīng)用,因?yàn)檩^難發(fā)揮多核的威力。
方案 6:這是一個(gè)過渡方案,收到 Sudoku 請求之后,不在 reactor 線程計(jì)算,而是創(chuàng)建一個(gè)新線程去計(jì)算,以充分利用多核 CPU。這是非常初級的多線程應(yīng)用,因?yàn)樗鼮槊總€(gè)請求(而不是每個(gè)連接)創(chuàng)建了一個(gè)新線程。這個(gè)開銷可以用線程池來避免,即方案 8。這個(gè)方案還有一個(gè)特點(diǎn)是 out-of-order,即同時(shí)創(chuàng)建多個(gè)線程去計(jì)算同一個(gè)連接上收到的多個(gè)請求,那么算出結(jié)果的次序是不確定的,可能第 2 個(gè) Sudoku 比較簡單,比第 1 個(gè)先算出結(jié)果。這也是為什么我們在一開始設(shè)計(jì)協(xié)議的時(shí)候使用了 id,以便客戶端區(qū)分 response 對應(yīng)的是哪個(gè) request。
方案 7:為了讓返回結(jié)果的順序確定,我們可以為每個(gè)連接創(chuàng)建一個(gè)計(jì)算線程,每個(gè)連接上的請求固定發(fā)給同一個(gè)線程去算,先到先得。這也是一個(gè)過渡方案,因?yàn)椴l(fā)連接數(shù)受限于線程數(shù)目,這個(gè)方案或許還不如直接使用阻塞 IO 的 thread-per-connection 方案2。方案 7 與方案 6 的另外一個(gè)區(qū)別是一個(gè) client 的最大 CPU 占用率,在方案 6 中,一個(gè) connection 上發(fā)來的一長串突發(fā)請求(burst requests) 可以占滿全部 8 個(gè) core;而在方案 7 中,由于每個(gè)連接上的請求固定由同一個(gè)線程處理,那么它最多占用 12.5% 的 CPU 資源。這兩種方案各有優(yōu)劣,取決于應(yīng)用場景的需要,到底是公平性重要還是突發(fā)性能重要。這個(gè)區(qū)別在方案 8 和方案 9 中同樣存在,需要根據(jù)應(yīng)用來取舍。
方案 8:為了彌補(bǔ)方案 6 中為每個(gè)請求創(chuàng)建線程的缺陷,我們使用固定大小線程池,程序結(jié)構(gòu)如下圖。全部的 IO 工作都在一個(gè) reactor 線程完成,而計(jì)算任務(wù)交給 thread pool。如果計(jì)算任務(wù)彼此獨(dú)立,而且 IO 的壓力不大,那么這種方案是非常適用的。Sudoku Solver 正好符合。代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_threadpool.cc 后文給出了它與方案 9 的區(qū)別。

如果 IO 的壓力比較大,一個(gè) reactor 忙不過來,可以試試 multiple reactors 的方案 9。
方案 9:這是 muduo 內(nèi)置的多線程方案,也是 Netty 內(nèi)置的多線程方案。這種方案的特點(diǎn)是 one loop per thread,有一個(gè) main reactor 負(fù)責(zé) accept 連接,然后把連接掛在某個(gè) sub reactor 中(muduo 采用 round-robin 的方式來選擇 sub reactor),這樣該連接的所有操作都在那個(gè) sub reactor 所處的線程中完成。多個(gè)連接可能被分派到多個(gè)線程中,以充分利用 CPU。Muduo 采用的是固定大小的 reactor pool,池子的大小通常根據(jù) CPU 核數(shù)確定,也就是說線程數(shù)是固定的,這樣程序的總體處理能力不會隨連接數(shù)增加而下降。另外,由于一個(gè)連接完全由一個(gè)線程管理,那么請求的順序性有保證,突發(fā)請求也不會占滿全部 8 個(gè)核(如果需要優(yōu)化突發(fā)請求,可以考慮方案 10)。這種方案把 IO 分派給多個(gè)線程,防止出現(xiàn)一個(gè) reactor 的處理能力飽和。與方案 8 的線程池相比,方案 9 減少了進(jìn)出 thread pool 的兩次上下文切換。我認(rèn)為這是一個(gè)適應(yīng)性很強(qiáng)的多線程 IO 模型,因此把它作為 muduo 的默認(rèn)線程模型。

代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_multiloop.cc
server_multiloop.cc 與 server_basic.cc 的區(qū)別很小,關(guān)鍵只有一行代碼:server_.setThreadNum(numThreads);
$ diff server_basic.cc server_multiloop.cc -up
--- server_basic.cc 2011-06-15 13:40:59.000000000 +0800
+++ server_multiloop.cc 2011-06-15 13:39:53.000000000 +0800
@@ -21,19 +21,22 @@ using namespace muduo::net;
class SudokuServer
{
public:
- SudokuServer(EventLoop* loop, const InetAddress& listenAddr)
+ SudokuServer(EventLoop* loop, const InetAddress& listenAddr, int numThreads)
: loop_(loop),
server_(loop, listenAddr, "SudokuServer"),
+ numThreads_(numThreads),
startTime_(Timestamp::now())
{
server_.setConnectionCallback(
boost::bind(&SudokuServer::onConnection, this, _1));
server_.setMessageCallback(
boost::bind(&SudokuServer::onMessage, this, _1, _2, _3));
+ server_.setThreadNum(numThreads);
}
方案 8 使用 thread pool 的代碼與使用多 reactors 的方案 9 相比變化不大,只是把原來 onMessage() 中涉及計(jì)算和發(fā)回響應(yīng)的部分抽出來做成一個(gè)函數(shù),然后交給 ThreadPool 去計(jì)算。記住方案 8 有 out-of-order 的可能,客戶端要根據(jù) id 來匹配響應(yīng)。
$ diff server_multiloop.cc server_threadpool.cc -up
--- server_multiloop.cc 2011-06-15 13:39:53.000000000 +0800
+++ server_threadpool.cc 2011-06-15 14:07:52.000000000 +0800
@@ -31,12 +32,12 @@ class SudokuServer
boost::bind(&SudokuServer::onConnection, this, _1));
server_.setMessageCallback(
boost::bind(&SudokuServer::onMessage, this, _1, _2, _3));
- server_.setThreadNum(numThreads);
}
void start()
{
LOG_INFO << "starting " << numThreads_ << " threads.";
+ threadPool_.start(numThreads_);
server_.start();
}
@@ -68,15 +69,7 @@ class SudokuServer
}
if (request.size() == implicit_cast<size_t>(kCells))
{
- string result = solveSudoku(request);
- if (id.empty())
- {
- conn->send(result+"\r\n");
- }
- else
- {
- conn->send(id+":"+result+"\r\n");
- }
+ threadPool_.run(boost::bind(solve, conn, request, id));
}
else
{
@@ -91,8 +84,23 @@ class SudokuServer
}
}
+ static void solve(const TcpConnectionPtr& conn, const string& request, const string& id)
+ {
+ LOG_DEBUG << conn->name();
+ string result = solveSudoku(request);
+ if (id.empty())
+ {
+ conn->send(result+"\r\n");
+ }
+ else
+ {
+ conn->send(id+":"+result+"\r\n");
+ }
+ }
+
EventLoop* loop_;
TcpServer server_;
+ ThreadPool threadPool_;
int numThreads_;
Timestamp startTime_;
};
完整代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_threadpool.cc
方案 10:把方案 8 和方案 9 混合,既使用多個(gè) reactors 來處理 IO,又使用線程池來處理計(jì)算。這種方案適合既有突發(fā) IO (利用多線程處理多個(gè)連接上的 IO),又有突發(fā)計(jì)算的應(yīng)用(利用線程池把一個(gè)連接上的計(jì)算任務(wù)分配給多個(gè)線程去做)。

這種其實(shí)方案看起來復(fù)雜,其實(shí)寫起來很簡單,只要把方案 8 的代碼加一行 server_.setThreadNum(numThreads); 就行,這里就不舉例了。
結(jié)語
我在《多線程服務(wù)器的常用編程模型》一文中說
總結(jié)起來,我推薦的多線程服務(wù)端編程模式為:event loop per thread + thread pool。
- event loop 用作 non-blocking IO 和定時(shí)器。
- thread pool 用來做計(jì)算,具體可以是任務(wù)隊(duì)列或消費(fèi)者-生產(chǎn)者隊(duì)列。
當(dāng)時(shí)(2010年2月)我還說“以這種方式寫服務(wù)器程序,需要一個(gè)優(yōu)質(zhì)的基于 Reactor 模式的網(wǎng)絡(luò)庫來支撐,我只用過in-house的產(chǎn)品,無從比較并推薦市面上常見的 C++ 網(wǎng)絡(luò)庫,抱歉。”
現(xiàn)在有了 muduo 網(wǎng)絡(luò)庫,我終于能夠用具體的代碼示例把思想完整地表達(dá)出來。