libco源碼解析(1) 協程運行與基本結構
libco源碼解析(2) 創建協程,co_create
libco源碼解析(3) 協程執行,co_resume
libco源碼解析(4) 協程切換,coctx_make與coctx_swap
libco源碼解析(5) poll
libco源碼解析(6) co_eventloop
libco源碼解析(7) read,write與條件變量
libco源碼解析(8) hook機制探究
libco源碼解析(9) closure實現
引言
我挑選了幾個比較有代表性的hook後的函數來說明hook後的函數具體幹了什麼,其他的函數也基本是大同小異。當然至此還是沒有討論hook機制,就放在下一篇文章中吧。
再看這些函數之前我們要知道爲什麼需要hook,當用戶創建阻塞套接字的時候,如果操作了這些套接字會導致線程切換,這不是我們希望看到的,我們希望能夠在用戶無感的情況下把把同步的操作替換爲異步。這需要我們在調用系統調用的時候加一些代碼,這也是使用hook的原因。
read
typedef ssize_t (*read_pfn_t)(int fildes, void *buf, size_t nbyte);
static read_pfn_t g_sys_read_func = (read_pfn_t)dlsym(RTLD_NEXT,"read");
ssize_t read( int fd, void *buf, size_t nbyte )
{
HOOK_SYS_FUNC( read );
// 如果目前線程沒有一個協程, 則直接執行系統調用
if( !co_is_enable_sys_hook() )
{ // dlsym以後得到的原函數
return g_sys_read_func( fd,buf,nbyte );
}
// 獲取這個文件描述符的詳細信息
rpchook_t *lp = get_by_fd( fd );
// 套接字爲非阻塞的,直接進行系統調用
if( !lp || ( O_NONBLOCK & lp->user_flag ) )
{
ssize_t ret = g_sys_read_func( fd,buf,nbyte );
return ret;
}
// 套接字阻塞
int timeout = ( lp->read_timeout.tv_sec * 1000 )
+ ( lp->read_timeout.tv_usec / 1000 );
struct pollfd pf = { 0 };
pf.fd = fd;
pf.events = ( POLLIN | POLLERR | POLLHUP );
// 調用co_poll, co_poll中會切換協程,
// 協程被恢復時將會從co_poll中的掛起點繼續運行
int pollret = poll( &pf,1,timeout );
// 套接字準備就緒或者超時 執行hook前的系統調用
ssize_t readret = g_sys_read_func( fd,(char*)buf ,nbyte );
if( readret < 0 ) // 超時
{
co_log_err("CO_ERR: read fd %d ret %ld errno %d poll ret %d timeout %d",
fd,readret,errno,pollret,timeout);
}
// 成功讀取
return readret;
}
我們可以看到一個有意思的結構,即rpchook_t
,我們來看看其定義:
//還有一個很重要的作用就是在libco中套接字在hook後的fcntl中都設置爲非阻塞,這裏保存了套接字原有的阻塞屬性
struct rpchook_t
{
int user_flag; // 記錄套接字的狀態
struct sockaddr_in dest; //maybe sockaddr_un; // 套機字目標地址
int domain; //AF_LOCAL->域套接字 , AF_INET->IP // 套接字類型
struct timeval read_timeout; // 讀超時時間
struct timeval write_timeout; // 寫超時時間
};
你也許會覺得這東西好像不是必要的,因爲fcntl可以得到相同的效果。但是這個結構其實是必須的。我們前面說到了爲了使用戶無感的從同步切換成異步,我們需要把用戶實際創建的阻塞套接字轉化成非阻塞套接字,但是如果用戶要fcntl 的時候又要返回給他他所定義的那一個,此時fnntl當然不行,因爲它是套接字的實際屬性,而不一定是用戶設置的屬性。
get_by_fd比較簡單,就不提了。
我們可以看到當用戶設置的套接字屬性本來就是非阻塞的時候直接調用原read即可。
然後就是把目標fd註冊到epoll中,等待fd事件來臨或者超時時切換回來即可,當然我們前面說過,poll幫我們做了這一切。
最後就是判斷read的返回值啦。
write
ssize_t write( int fd, const void *buf, size_t nbyte )
{
HOOK_SYS_FUNC( write );
if( !co_is_enable_sys_hook() )
{
return g_sys_write_func( fd,buf,nbyte );
}
rpchook_t *lp = get_by_fd( fd );
// 我覺得這裏有必要再強調一遍,user_flag是用戶設定的,但對於libco來說,
// 所有由hook函數創建的套接字對系統來說都是非阻塞的
if( !lp || ( O_NONBLOCK & lp->user_flag ) )
{
ssize_t ret = g_sys_write_func( fd,buf,nbyte );
return ret;
}
size_t wrotelen = 0; //已寫的長度
int timeout = ( lp->write_timeout.tv_sec * 1000 ) // 計算超時時間
+ ( lp->write_timeout.tv_usec / 1000 );
// 因爲TCP協議的原因,有時可能因爲ask中接收方窗口小於write大小的原因無法發滿
ssize_t writeret = g_sys_write_func( fd,(const char*)buf + wrotelen,nbyte - wrotelen );
if (writeret == 0)
{
return writeret;
}
if( writeret > 0 )
{
wrotelen += writeret;
}
// 一次可能無法寫入完全,發生在TCP發送窗口小於要發送的數據大小的時候,通常是對端數據堆積
while( wrotelen < nbyte )
{
struct pollfd pf = { 0 };
pf.fd = fd;
pf.events = ( POLLOUT | POLLERR | POLLHUP );
poll( &pf,1,timeout );
writeret = g_sys_write_func( fd,(const char*)buf + wrotelen,nbyte - wrotelen );
if( writeret <= 0 )
{
break;
}
wrotelen += writeret ;
}
if (writeret <= 0 && wrotelen == 0)
{
return writeret;
}
return wrotelen;
}
write的過程也比較容易,需要注意的一點是這裏判斷了如果一次寫入沒有寫滿的情況,這種情況其實是非常必要的,但也通常是網絡編程新手所容易忽視的,當TCP發送窗口小於要發送的數據大小的時候,就會出現一次發不完的情況。所以一般需要循環發送。
條件變量
條件變量和其他的函數不太一樣,它並不是簡單的hook一下,而是根據libco的架構重新設計了一個協程版的條件變量,究其原因就是條件變量條件何時滿足用epoll並不太方便,如果硬要那麼寫也可以,每一個條件變量分配一個fd就可以了,libco基於co_eventloop採用了更爲高效的方法。
我們先來看看條件變量的實體,非常簡單:
struct stCoCondItem_t
{
stCoCondItem_t *pPrev;
stCoCondItem_t *pNext;
stCoCond_t *pLink; // 所屬鏈表
stTimeoutItem_t timeout;
};
struct stCoCond_t // 條件變量的實體
{
stCoCondItem_t *head;
stCoCondItem_t *tail;
};
除去鏈表相關,只剩下了一個stTimeoutItem_t
結構,記性好的朋友會記得在poll中我們說過這個結構,它是一個單獨的事件,在poll中時一個stTimeoutItem_t代表一個poll事件。
co_cond_timedwait
首先我們來看看和pthread_cond_wait
語義相同的co_cond_timedwait
到底幹了什麼:
// 條件變量的實體;超時時間
int co_cond_timedwait( stCoCond_t *link,int ms )
{
stCoCondItem_t* psi = (stCoCondItem_t*)calloc(1, sizeof(stCoCondItem_t));
psi->timeout.pArg = GetCurrThreadCo();
// 實際還是執行resume,進行協程切換
psi->timeout.pfnProcess = OnSignalProcessEvent;
if( ms > 0 )
{
unsigned long long now = GetTickMS();
// 定義超時時間
psi->timeout.ullExpireTime = now + ms;
// 加入時間輪
int ret = AddTimeout( co_get_curr_thread_env()->pEpoll->pTimeout,&psi->timeout,now );
if( ret != 0 )
{
free(psi);
return ret;
}
}
// 相當於timeout爲負的話超時時間無限,此時條件變量中有一個事件在等待,也就是一個協程待喚醒
AddTail( link, psi);
co_yield_ct(); // 切換CPU執行權,切換CPU執行權,在epoll中觸發peocess回調以後回到這裏
// 這個條件要麼被觸發,要麼已經超時,從條件變量實體中刪除
RemoveFromLink<stCoCondItem_t,stCoCond_t>( psi );
free(psi);
return 0;
}
我們可以看到實現非常簡單,條件變量的實體就是一條鏈表,其中存着stCoCondItem_t
結構,在wait時創建一個stCoCondItem_t
結構把其插入到代表條件變量實體的鏈表中,然後就切換CPU執行權,當然這個如果註冊了超時時間也會被放入到時間輪中。
等到再次執行的時候要麼超時要麼被signal了,就從條件變量的鏈表中移除即可。
co_cond_signal
int co_cond_signal( stCoCond_t *si )
{
stCoCondItem_t * sp = co_cond_pop( si );
if( !sp )
{
return 0;
}
// 從時間輪中移除
RemoveFromLink<stTimeoutItem_t,stTimeoutItemLink_t>( &sp->timeout );
// 加到active隊列中,回想co_eventloop中對於active鏈表是否應該是局部變量的討論
AddTail( co_get_curr_thread_env()->pEpoll->pstActiveList,&sp->timeout );
// 所以單線程運行生產者消費者我們在signal以後還需要調用阻塞類函數轉移CPU控制權,例如poll
return 0;
}
這裏的邏輯也就很簡單了,查看鏈表是否有元素,有的話從鏈表中刪除,然後加入到epoll的active鏈表,在下一次epoll_wait中遍歷active時會觸發回調,然後CPU執行權切換到執行co_cond_timedwait
的地方。
當然co_cond_broadcast
和co_cond_signal
的邏輯都是差不多的,就是多了一個循環而已啦。