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​​的逻辑都是差不多的,就是多了一个循环而已啦。