libco源碼解析(5) poll

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實現

引言

poll是libco中所有hook後的函數中可以說是最重要的一個,因爲我們不但可以這個函數來隱式的轉移CPU執行權,而且其他hook後的函數還可以使用這個hook後的poll在不切換線程的情況下去監聽套接字,並在超時或者套接字有事件到來的時候喚醒這個調用poll的協程。

在example_cond.cpp中我們也可以看到使用poll去切換執行權的操作,也正因爲此,我們才得以完成使用一個線程去完成一個完整的生產者消費者模型。

正文

關於libco的hook技術原理,我們會在後續文章中去講解,現在知道它的作用是使用自己的函數去替換庫函數就可以了。我們先來看看hook後的poll函數的實現:

typedef int (*poll_pfn_t)(struct pollfd fds[], nfds_t nfds, int timeout);
static poll_pfn_t g_sys_poll_func 		= (poll_pfn_t)dlsym(RTLD_NEXT,"poll");

// 在hook以後的poll中應該執行協程的調度
int poll(struct pollfd fds[], nfds_t nfds, int timeout)
{ 
	HOOK_SYS_FUNC( poll );

	// 如果本線程不存在協程或者超時時間爲零的話調用hook前的poll
	if (!co_is_enable_sys_hook() || timeout == 0) {
		return g_sys_poll_func(fds, nfds, timeout);
	}

	pollfd *fds_merge = NULL;
	nfds_t nfds_merge = 0; // 相當於一個單調遞增的標誌位
	std::map<int, int> m;  // fd --> idx
	std::map<int, int>::iterator it;
	if (nfds > 1) {
		fds_merge = (pollfd *)malloc(sizeof(pollfd) * nfds);
		for (size_t i = 0; i < nfds; i++) {
			if ((it = m.find(fds[i].fd)) == m.end()) { // 在mp中沒有找到
				fds_merge[nfds_merge] = fds[i]; // 放入merge鏈表
				m[fds[i].fd] = nfds_merge; // 沒找到就放進去
				nfds_merge++; // 遊標遞增
			} else {
				int j = it->second;
				fds_merge[j].events |= fds[i].events;  // merge in j slot
			}
		} 
	} 
	// 以上就相當於是一個小優化,就是查看此次poll中是否有fd相同的事件,有的話合併一下,僅此而已

	int ret = 0; 
	if (nfds_merge == nfds || nfds == 1) {// 沒有執行合併
		// fds爲poll的事件;nfds爲事件數;timeout爲超時時間;g_sys_poll_func爲未hook的poll函數
		// 返回值爲此次就緒的事件數 
		// 在co_poll_inner中有一個協程的切換
		ret = co_poll_inner(co_get_epoll_ct(), fds, nfds, timeout, g_sys_poll_func);
	} else {
		ret = co_poll_inner(co_get_epoll_ct(), fds_merge, nfds_merge, timeout,
				g_sys_poll_func);
		if (ret > 0) {
			// 把merge的事件還原一下
			for (size_t i = 0; i < nfds; i++) {
				it = m.find(fds[i].fd);
				if (it != m.end()) {
					int j = it->second;
					fds[i].revents = fds_merge[j].revents & fds[i].events;
				}
			}
		}
	}
	free(fds_merge);
	return ret;
}

其實hook後的poll函數最重要的一步就是調用了co_poll_inner,其他的只不過是一些檢查罷了。

co_is_enable_sys_hook函數其實就是獲取當前線程正在執行的協程,並查看其cEnableSysHook,cEnableSysHook在我們的函數中執行co_enable_hook_sys時設置爲1。你可能會問,如果沒有執行cEnableSysHook的話根本就不會使用hook後的poll啊,因爲這個文件沒有被鏈接進入項目,那麼想想看兩個函數,一個有cEnableSysHook,一個沒有,沒有的那個我們顯然不希望使用hook後的函數。這也是這段檢查的目的所在。

這裏還蘊藏着一點,就是co_is_enable_sys_hook中判斷co是否爲空,而GetCurrThreadCo會在當前env沒有被初始化的時候返回空。GetCurrThreadCo爲什麼不在判斷爲空時創建一個呢?因爲在函數中可能主協程去運行一個帶有cEnableSysHook的函數,這個時候我們顯然不希望執行hook後的poll。具體可參見example_poll.cpp

bool co_is_enable_sys_hook()
{	
	// co_enable_sys_hook 會把cEnableSysHook設置成1
	stCoRoutine_t *co = GetCurrThreadCo();
	return ( co && co->cEnableSysHook );
}

stCoRoutine_t *GetCurrThreadCo( ) 
{
	stCoRoutineEnv_t *env = co_get_curr_thread_env();
	// 爲什麼返回0而不就地初始化呢?看似簡單的一筆,與example_poll相關
	if( !env ) return 0;	
	return GetCurrCo(env);
}

注意一點,就是所有經由hook後的socket創建的套接字都是非阻塞的,可能用戶想要創建一個阻塞的,但是在libco看來它們都是非阻塞的,而顯示給用戶的時候還是阻塞的。所以在timeout爲0的時候直接執行原poll就可以了,因爲非阻塞,這裏也不會發生線程的切換,始終在一個線程內運行。

然後是一大段看起來複雜,實際沒啥難度的代碼,意義就是合併傳入的poll事件,至於合併,也就是把不同項但是同一fd的各個poll的項和起來,僅此而已。當然在執行完co_poll_inner之後還需要把合併後的事件再展開,使得整個過程對於用戶而言是無感知的。

co_poll_inner

我們接下來看看co_poll_inner函數的實現,這個還是比較麻煩的,其中涉及到了協程的切換,其實也就是這個正在執行的協程讓出自己的執行權給調用它的哪一個協程,從這裏也可以看出libco是一個典型的非對稱協程

int co_poll_inner( stCoEpoll_t *ctx,struct pollfd fds[], nfds_t nfds, int timeout, poll_pfn_t pollfunc)
{
	// 超時時間爲零 直接執行系統調用 感覺這直接在hook的poll中判斷就好了
    if (timeout == 0) 
	{
		return pollfunc(fds, nfds, timeout);
	}
	if (timeout < 0) // 搞不懂這是什麼意思,小於零就看做無限阻塞?
	{
		timeout = INT_MAX;
	}
	// epoll fd
	int epfd = ctx->iEpollFd;
	// 獲取當前線程正在運行的協程
	stCoRoutine_t* self = co_self();

	//1.struct change
	// 一定要把這stPoll_t, stPollItem_t之間的關係看清楚
	stPoll_t& arg = *((stPoll_t*)malloc(sizeof(stPoll_t)));
	// 指針的初始化,非常關鍵,不加的話在addtail的條件判斷中會出現問題
	memset( &arg,0,sizeof(arg) );

	arg.iEpollFd = epfd;
	arg.fds = (pollfd*)calloc(nfds, sizeof(pollfd));
	arg.nfds = nfds;

	// 一個小優化 數據量少的時候少一次系統調用
	stPollItem_t arr[2];
	if( nfds < sizeof(arr) / sizeof(arr[0]) && !self->cIsShareStack)
	{
		// 如果poll中監聽的描述符只有1個或者0個, 並且目前的不是共享棧模型
		arg.pPollItems = arr;
	}	
	else
	{
		arg.pPollItems = (stPollItem_t*)malloc( nfds * sizeof( stPollItem_t ) );
	}
	memset( arg.pPollItems,0,nfds * sizeof(stPollItem_t) );

	// 在eventloop中調用的處理函數,功能是喚醒pArg中的協程,也就是這個調用poll的協程
	arg.pfnProcess = OnPollProcessEvent;  
	arg.pArg = GetCurrCo( co_get_curr_thread_env());
	
	
	//2. add epoll
	for(nfds_t i=0;i<nfds;i++)
	{
		arg.pPollItems[i].pSelf = arg.fds + i; // 第i個poll事件
		arg.pPollItems[i].pPoll = &arg;

		// 設置一個預處理回調 這個回調做的事情是把此事件從超時隊列轉到就緒隊列
		arg.pPollItems[i].pfnPrepare = OnPollPreparePfn;
		// ev是arg.pPollItems[i].stEvent的一個引用,這裏就相當於是縮寫了

		// epoll_event 就是epoll需要的事件類型
		// 這個結構直接插在紅黑樹中,時間到來或超時我們可以拿到其中的data
		// 一般我用的時候枚舉中只使用fd,這裏使用了一個指針
		struct epoll_event &ev = arg.pPollItems[i].stEvent;

		if( fds[i].fd > -1 ) // fd有效
		{
			ev.data.ptr = arg.pPollItems + i;
			ev.events = PollEvent2Epoll( fds[i].events );

			// 把事件加入poll中的事件進行封裝以後加入epoll
			int ret = co_epoll_ctl( epfd,EPOLL_CTL_ADD, fds[i].fd, &ev );
			if (ret < 0 && errno == EPERM && nfds == 1 && pollfunc != NULL)
			{ //加入epoll失敗 且nfds只有一個
				if( arg.pPollItems != arr )
				{
					free( arg.pPollItems );
					arg.pPollItems = NULL;
				}
				free(arg.fds);
				free(&arg);
				return pollfunc(fds, nfds, timeout);
			}
		}
		//if fail,the timeout would work
	}

	//3.add timeout

	// 獲取當前時間
	unsigned long long now = GetTickMS();

	// 超時時間
	arg.ullExpireTime = now + timeout;
	
	// 添加到超時鏈表中 
	int ret = AddTimeout( ctx->pTimeout,&arg,now );
	int iRaiseCnt = 0;

	// 正常返回return 0
	if( ret != 0 )
	{
		co_log_err("CO_ERR: AddTimeout ret %d now %lld timeout %d arg.ullExpireTime %lld",
				ret,now,timeout,arg.ullExpireTime);
		errno = EINVAL;
		iRaiseCnt = -1;

	}
    else
	{
		// 讓出CPU, 切換到其他協程, 當事件到來的時候就會調用callback,那裏會喚醒此協程
		co_yield_env( co_get_curr_thread_env() );

		// --------------我是分割線---------------
		// 在預處理中執行+1, 也就是此次阻塞等待的事件中有幾個是實際發生了
		iRaiseCnt = arg.iRaiseCnt;
	}

    {
		// clear epoll status and memory
		// 將該項從時間輪中刪除
		RemoveFromLink<stTimeoutItem_t,stTimeoutItemLink_t>( &arg );
		// 將此次poll中涉及到的時間全部從epoll中刪除 
		// 這意味着有一些事件沒有發生就被終止了 
		// 比如poll中3個事件,實際觸發了兩個,最後一個在這裏就被移出epoll了
		for(nfds_t i = 0;i < nfds;i++)
		{
			int fd = fds[i].fd;
			if( fd > -1 )
			{
				co_epoll_ctl( epfd,EPOLL_CTL_DEL,fd,&arg.pPollItems[i].stEvent );
			}
			fds[i].revents = arg.fds[i].revents;
		}


		// 釋放內存 當然使用智能指針就沒這事了
		if( arg.pPollItems != arr )
		{
			free( arg.pPollItems );
			arg.pPollItems = NULL;
		}

		free(arg.fds);
		free(&arg);
	}
	// 返回此次就緒或者超時的事件
	return iRaiseCnt;
}

libco中本來的註釋把這個函數分爲三個部分:

  1. struct change:把poll的結構改爲epoll的結構。
  2. add epoll:把改變後的結構加入epoll。
  3. add timeout:把這個事件加入超時鏈表。這裏面涉及到了stPoll_t,stPollItem_t,stTimeoutItem_t,三個結構的轉化,前兩者都繼承於stTimeoutItem_t。搞清楚三個結構的轉換是理解這個函數的關鍵之處。總的來說stPoll_t是一個單獨的事件,可以放入到時間輪中,其中包含着stPollItem_t。每一個stPollItem_t是一個poll中的事件,都在轉化爲epoll事件後插入到epoll中。stTimeoutItem_t是前兩者的基類,一般用於函數接口,使得可以接收上面兩種參數,這裏配套了一些基於模板的鏈表的操作。

我們一一來看一看,當然我們挑一些值得一提的地方說一說,一般的步驟我都寫在了註釋中:

struct change

我們先來看看stPoll_t結構:

struct stPoll_t : public stTimeoutItem_t 
{
	struct pollfd *fds; // 描述poll中的事件
	nfds_t nfds; // typedef unsigned long int nfds_t;

	stPollItem_t *pPollItems; // 要加入epoll的事件 長度爲nfds

	int iAllEventDetach; // 標識是否已經處理過了這個對象了

	int iEpollFd; // epoll fd

	int iRaiseCnt; // 此次觸發的事件數
};

struct stPollItem_t : public stTimeoutItem_t
{
	struct pollfd *pSelf;// 對應的poll結構
	stPoll_t *pPoll;	// 所屬的stPoll_t

	struct epoll_event stEvent;	// poll結構所轉換的epoll結構
};

我們注意到在第一部分設置了一個回調OnPollProcessEvent,它的作用是epoll中檢測到事件超時或者事件就緒的時候執行的一個回調的,其實也就是執行了co_resume,從這裏我們可以看到執行協程切換我們實際上只需要一個co結構而已。

void OnPollProcessEvent( stTimeoutItem_t * ap )
{
	stCoRoutine_t *co = (stCoRoutine_t*)ap->pArg;
	co_resume( co );
}

add epoll

這裏我們注意到設置了一個預處理回調,算上前面那個回調,它們兩個都是在eventloop中被使用,我們下一篇文章會說說eventloop,這部分與Eventloop部分粘連較多,就先不說了,只貼上註釋的代碼。

void OnPollPreparePfn( stTimeoutItem_t * ap,struct epoll_event &e,stTimeoutItemLink_t *active )
{
	stPollItem_t *lp = (stPollItem_t *)ap;
	// 把epoll此次觸發的事件轉換成poll中的事件
	lp->pSelf->revents = EpollEvent2Poll( e.events );


	stPoll_t *pPoll = lp->pPoll;
	// 已經觸發的事件數加一
	pPoll->iRaiseCnt++;

	// 若此事件還未被觸發過
	if( !pPoll->iAllEventDetach )
	{
		// 設置已經被觸發的標誌
		pPoll->iAllEventDetach = 1;

		// 將該事件從時間輪中移除
		// 因爲事件已經觸發了,肯定不能再超時了
		RemoveFromLink<stTimeoutItem_t,stTimeoutItemLink_t>( pPoll );

		// 將該事件添加到active列表中
		AddTail( active,pPoll );
	}
}

add timeout

上一部分中我們已經把所有poll中的事件轉化成epoll事件,並插入到epoll中了。並且給每一個poll事件分配了一個stPollItem_t結構,這些stPollItem_t都是屬於結構爲stPoll_t的arg的,這裏我們再次重申一遍,時間輪的鏈表中存的實際是stPoll_t

我們來看看如何把一個stPoll_t插入一個時間輪:

/*
* 將事件添加到定時器中
* @param apTimeout - (ref) 超時管理器
* @param apItem    - (in) 即將插入的超時事件
* @param allNow    - (in) 當前時間
*/
int AddTimeout( stTimeout_t *apTimeout,stTimeoutItem_t *apItem ,unsigned long long allNow )
{
	// 當前時間管理器的最早超時時間
	if( apTimeout->ullStart == 0 )
	{
		// 設置時間輪的最早時間是當前時間
		apTimeout->ullStart = allNow;
		// 設置最早時間對應的index 爲 0
		apTimeout->llStartIdx = 0;
	}
	// 插入時間小於初始時間肯定是錯的
	if( allNow < apTimeout->ullStart )
	{
		co_log_err("CO_ERR: AddTimeout line %d allNow %llu apTimeout->ullStart %llu",
					__LINE__,allNow,apTimeout->ullStart);

		return __LINE__;
	}
	// 預期時間小於插入時間也是有問題的
	if( apItem->ullExpireTime < allNow )
	{
		co_log_err("CO_ERR: AddTimeout line %d apItem->ullExpireTime %llu allNow %llu apTimeout->ullStart %llu",
					__LINE__,apItem->ullExpireTime,allNow,apTimeout->ullStart);

		return __LINE__;
	}
	// 計算事件還有多長時間會超時
	unsigned long long diff = apItem->ullExpireTime - apTimeout->ullStart; 

	// 預期時間到現在不能超過時間輪的大小
	// 其實是可以的,只需要取餘放進去並加上一個圈數的成員就可以了
	// 遍歷時圈數不爲零就說明實際超時時間還有一個時間輪的長度,
	// 遍歷完一項以後圈數不爲零就減1即可
	if( diff >= (unsigned long long)apTimeout->iItemSize )
	{
		diff = apTimeout->iItemSize - 1;
		co_log_err("CO_ERR: AddTimeout line %d diff %d",
					__LINE__,diff);

		//return __LINE__;
	}
	// 時間輪粒度爲1毫秒,即一項代表一毫秒,說實話精度確實算是比較高了,我以前寫的是秒。不過毫秒必然會有很多項是空閒的吧。
	AddTail( apTimeout->pItems + ( apTimeout->llStartIdx + diff ) % apTimeout->iItemSize , apItem );

	return 0;
}

基本的邏輯都已經在註釋中啦。AddTail實際執行了把stPoll_t插入到時間輪中。

我們可以看到在把時間加入到時間輪中成功以後會調用co_yield_env,這個函數會使得當前協程讓出CPU,把執行權交給調用此協程的協程。想想其實是非常合理的,已經把所有的事件加入到epoll了,這個函數也沒有什麼執行的必要了,靜靜等待事件完成或超時就可以了。

void co_yield_env( stCoRoutineEnv_t *env )
{
	
	stCoRoutine_t *last = env->pCallStack[ env->iCallStackSize - 2 ]; // 要切換的協程
	stCoRoutine_t *curr = env->pCallStack[ env->iCallStackSize - 1 ]; // 即當前正在執行的協程

	env->iCallStackSize--;

	co_swap( curr, last);
}

我們可以看到其實就是在線程獨有的調用棧中拿最新的兩個協程,正在執行的協程和調用正在執行的協程的協程(有點繞),然後把它們的上下文切換,即切換協程。

然後就是等待協程被切換回來了。

被切換回來以後可能只有一部分事件被觸發,這個時候libco的做法是把沒有被觸發的全部移出epoll,這樣避免了協程已經被epoll 中的回調調用,後面仍會觸發,並執行回調,就core dump了。

然後就是一般的資源釋放啦。

poll到這裏就結束了,可以看出它與eventloop的關係還是比較密切的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章