目录
简介
本博文是对开源网络库libhv中的示例程序tcpServer_test的学习记录。
流程介绍
1.打开TcpServer_test.cpp文件,看到main()
//运行程序时需要指定端口
int main(int argc, char* argv[]) {
if (argc < 2) {
printf("Usage: %s port\n", argv[0]);
return -10;
}
int port = atoi(argv[1]);
hlog_set_level(LOG_LEVEL_DEBUG);
TcpServer srv;//创建TcpServer
int listenfd = srv.createsocket(port);//创建socket,调用了socket()、bind()、listen()
if (listenfd < 0) {
return -20;
}
printf("server listen on port %d, listenfd=%d ...\n", port, listenfd);
//设置连接回调函数
srv.onConnection = [](const SocketChannelPtr& channel) {
std::string peeraddr = channel->peeraddr();
if (channel->isConnected()) {
printf("%s connected! connfd=%d id=%d tid=%ld\n", peeraddr.c_str(), channel->fd(), channel->id(), currentThreadEventLoop->tid());
} else {
printf("%s disconnected! connfd=%d id=%d tid=%ld\n", peeraddr.c_str(), channel->fd(), channel->id(), currentThreadEventLoop->tid());
}
};
srv.onMessage = [](const SocketChannelPtr& channel, Buffer* buf) {
// echo
printf("< %.*s\n", (int)buf->size(), (char*)buf->data());
channel->write(buf);
};
srv.setThreadNum(4);//设置线程数量
srv.setLoadBalance(LB_LeastConnections);//设置负载均衡模式
srv.start();//开启服务
std::string str;
while (std::getline(std::cin, str)) {
if (str == "close") {
srv.closesocket();
} else if (str == "start") {
srv.start();
} else if (str == "stop") {
srv.stop();
break;
} else {
srv.broadcast(str.data(), str.size());
}
}
return 0;
}
main主要有如下步骤
1.创建TcpServer
2.创建socket
3.设置onConnection()、onMessage()这两个回调函数,比较简单,不需要单独分析。
4.开启服务
下面挨个对main中的主要步骤进行说明
2. 创建TcpServer
也就是这行代码
TcpServer srv;//创建TcpServer
TcpServer定义
typedef TcpServerTmpl<SocketChannel> TcpServer;
接着看看TcpServerTmpl类的定义及构造函数
class TcpServerTmpl : private EventLoopThread, public TcpServerEventLoopTmpl<TSocketChannel> {
public:
TcpServerTmpl(EventLoopPtr loop = NULL)
: EventLoopThread(loop)
, TcpServerEventLoopTmpl<TSocketChannel>(EventLoopThread::loop())
, is_loop_owner(loop == NULL)
{}
......
}
TcpServerTmpl类继承EventLoopThread类和TcpServerEventLoopTmpl类。调用子类的构造函数时需要先通过初始化列表来构造父类,因此我们先来看看EventLoopThread类和TcpServerEventLoopTmpl类的定义及构造函数。
3. EventLoopThread类
class EventLoopThread : public Status {
public:
// Return 0 means OK, other failed.
typedef std::function<int()> Functor;
EventLoopThread(EventLoopPtr loop = NULL) {
setStatus(kInitializing);
loop_ = loop ? loop : std::make_shared<EventLoop>();
setStatus(kInitialized);
}
......
}
EventLoopThread类继承Status类,Status类封装了状态操作。
EventLoopThread构造函数中,判断loop是否为空,loop不为空则将其赋值给成员变量loop_,若为空则创建一个EventLoopPtr并赋值给loop_。EventLoopPtr是make_shared<EventLoop>()。
接下来看看EventLoop。
EventLoop类简单来说就是一个事件循环类,创建一个EventLoop对象就创建一个事件循环。EventLoop类是对libhv的底层结构体hloop_t的封装,封装了开始事件循环、停止事件循环、暂停事件循环、发送客户消息等函数。
EventLoopThread类就介绍这么多,简单来说TcpServerTmpl的初始列表中的EventLoopThread(loop)就是创建了一个事件循环。
回到步骤2中,接下来我们来看看初始化列表中的TcpServerEventLoopTmpl<TSocketChannel>(EventLoopThread::loop())
4.TcpServerEventLoopTmpl类
template<class TSocketChannel = SocketChannel>
class TcpServerEventLoopTmpl {
public:
typedef std::shared_ptr<TSocketChannel> TSocketChannelPtr;
TcpServerEventLoopTmpl(EventLoopPtr loop = NULL) {
acceptor_loop = loop ? loop : std::make_shared<EventLoop>();
port = 0;
listenfd = -1;
tls = false;
tls_setting = NULL;
unpack_setting = NULL;
max_connections = 0xFFFFFFFF;
load_balance = LB_RoundRobin;//设置负载均衡
}
...
}
该类是一个模板类,关于此处模板的作用,我们后面再分析。
该类的构造函数接收一个EventLoopPtr,
TcpServerTmpl(EventLoopPtr loop = NULL)
: EventLoopThread(loop) //创建一个事件循环
, TcpServerEventLoopTmpl<TSocketChannel>(EventLoopThread::loop())//EventLoopThread::loop()表示调用父类EventLoopThread的loop()
, is_loop_owner(loop == NULL)
{}
通过上面的代码可知,EventLoopPtr是通过EventLoopThread的loop()函数获取的,而loop()函数返回的EventLoopPtr是在EventLoopThread类的构造函数中创建的。
在TcpServerEventLoopTmpl类的构造函数中,将EventLoopPtr赋值给成员变量acceptor_loop,将该事件循环作为accept消息循环,它专门用来处理客户的连接事件。在构造函数中还有一个重要操作是设置了负载均衡策略为LB_RoundRobin,关于负载均衡后面还会提及到。
分析完了EventLoopThread类和TcpServerEventLoopTmpl类的定义及构造函数也就将TcpServerTmpl类的构造函数分析完了,总结一下,创建TcpServerTmpl类对象包括创建一个事件循环,然后将该事件循环的指针拷贝到TcpServerEventLoopTmpl类中进行管理,将该消息循环作为专门的accept消息循环。
回到步骤1中,现在应该分析创建socket了
5.创建socket
创建socket也就是main()中的这行代码
int listenfd = srv.createsocket(port);//调用了socket()、bind()、listen()
createsocket()实际也就是调用了socket()、bind()、listen()这几个系统调用,将创建的fd保存在TcpServerEventLoopTmpl类的成员变量中。对于阻塞IO来创建TCPServer的流程来说,调用socket()、bind()、listen()之后,接着就应该调用accept()来等待客户连接(listen()不是阻塞的),但该例程使用的是非阻塞IO,fd的非阻塞属性并不是在该函数中设置的,而是在将fd放入epoll监听之前设置的,后面的分析中还会提到这一点的。
回到步骤1中,现在应该分析创建服务了。
6.开启服务
也就是main()中的这行代码
srv.start();//开启服务
看看start()的实现,分别调用了父类TcpServerEventLoopTmpl和父类EventLoopThread的start()。参数wait_threads_stopped的含义是创建子线程时是否等待子线程运行起来。
// start thread-safe
void start(bool wait_threads_started = true) {
TcpServerEventLoopTmpl<TSocketChannel>::start(wait_threads_started);
EventLoopThread::start(wait_threads_started);
}
我们分别看看父类TcpServerEventLoopTmpl的start()和父类EventLoopThread的start()。
7. TcpServerEventLoopTmpl的start()
先看看start()的实现
void start(bool wait_threads_started = true) {
if (worker_threads.threadNum() > 0)
{
//启动线程池
worker_threads.start(wait_threads_started);
}
//启动accept事件循环
acceptor_loop->runInLoop(std::bind(&TcpServerEventLoopTmpl::startAccept, this));
}
start()中主要做了两个工作
一是启动线程池,worker_threads是类TcpServerEventLoopTmpl的成员, 其类型是EventLoopThreadPool,EventLoopThreadPool是一个事件循环线程池类,管理多个EventLoopThread,管理线程池的启停和各个事件循环的负载均衡。worker_threads.threadNum在main()中设置,worker_threads.start()即启动事件循环线程池,在worker_threads.start()中的主要工作是创建并启动事件循环,所有的事件循环放在worker_threads的成员loop_threads_中,loop_threads_是一个集合。
二是启动accept事件循环
runInloop()中只是选择在哪执行startAccept(),看看startAccept()的代码
int startAccept()
{
if (listenfd < 0) {
listenfd = createsocket(port, host.c_str());
if (listenfd < 0) {
hloge("createsocket %s:%d return %d!\n", host.c_str(), port, listenfd);
return listenfd;
}
}
hloop_t* loop = acceptor_loop->loop();
if (loop == NULL) return -2;
hio_t* listenio = haccept(loop, listenfd, onAccept);//调用haccpet()
assert(listenio != NULL);
hevent_set_userdata(listenio, this);//设置用户数据,用户数据是当前对象
if (tls) {
hio_enable_ssl(listenio);
if (tls_setting) {
int ret = hio_new_ssl_ctx(listenio, tls_setting);
if (ret != 0) {
hloge("new SSL_CTX failed: %d", ret);
closesocket();
return ret;
}
}
}
return 0;
}
在haccept()中创建了hio_t对象,该对象保存IO的信息,比如fd、读回调函数、写回调函数、关闭连接回调函数、用户数据等;将socket设置成非阻塞;将acceptfd放入epoll中进行监听;设置连接回调函数onAccept()。
TcpServerEventLoopTmpl的start()函数就介绍完了,总结一下,开启了一个线程池;开启了accept事件循环、设置了连接回调函数、将acceptfd放入到epoll中进行监听。记住这几点,后面还会提到。同时我们也看出了TcpServerEventLoopTmpl类的主要作用,它管理一个线程池和一个accept事件循环。
回到步骤6中,接下来应该介绍父类EventLoopThread的start()了。
8.EventLoopThread的start()
void start(bool wait_thread_started = true,
Functor pre = Functor(),
Functor post = Functor())
{
//判断线程是否启动,若没有启动则启动线程
if (status() >= kStarting && status() < kStopped) return;
setStatus(kStarting);
thread_ = std::make_shared<std::thread>(&EventLoopThread::loop_thread, this, pre, post);
if (wait_thread_started) {
while (loop_->status() < kRunning) {
hv_delay(1);
}
}
}
EventLoopThread的start()比较简单,无需过多介绍。
介绍完父类TcpServerEventLoopTmpl的start()和父类EventLoopThread的start()步骤6也就介绍完了,至此main()也就介绍完了。那么这个程序接下来是怎么运行的了?还记得我们步骤7中说的将acceptfd放入epoll中进行监听嘛?对喽,接下来就是等待客户连接了,有客户连接时epoll就会触发,事件循环就该登场来处理这个连接了,从epoll触发到事件循环的处理不是一两句能说清楚,后面我会用一章专门来介绍这一点,现在你只需要知道最终调用了步骤7中提到的连接回调函数onAccept(),所以接下来我们应该来看看onAccept()。
9.onAccept()
分两步来介绍onAccept(),第一onAccept()是在哪被调用的?第二onAccept()函数中做了什么?
onAccept()在哪被调用?
从步骤7中的代码我们可以看到,onAccept函数作为参数传递给函数haccept(),所以我们来看看该函数的代码
hio_t* haccept(hloop_t* loop, int listenfd, haccept_cb accept_cb) {
hio_t* io = hio_get(loop, listenfd);//创建IO
assert(io != NULL);
if (accept_cb) {
io->accept_cb = accept_cb;//将连接回调函数accept_cb保存在io中
}
if (hio_accept(io) != 0) return NULL;
return io;
}
接下来看看hio_accept(),该函数定义在nio.c中,即非阻塞io,
int hio_accept(hio_t* io) {
io->accept = 1;//设置该io是是用来accept客户端的
/*hio_add就是将io加入到io事件监视器中,并指定自己需要关注的事件类型是HV_READ可读属性。
因为当客户端连接服务器时,会令监听套接字成为可读的。当监听套接字的可读事件触发时,
会调用hio_handle_events函数
*/
//向事件轮中添加IO读写事件,hio_handle_events()为IO事件回调函数,在其内部进行读、写、accept事件的处理
return hio_add(io, hio_handle_events, HV_READ);
}
当epoll被触发之后,事件循环流程会先调用hio_handle_events(),在hio_handle_events()中进行一些操作之后再调用hio_accept()。
onAccept()函数中做了什么?
看看代码
static void onAccept(hio_t* connio)
{
TcpServerEventLoopTmpl* server = (TcpServerEventLoopTmpl*)hevent_userdata(connio);
// NOTE: detach from acceptor loop
hio_detach(connio);//将连接进来的io从accept loop中剥离出来
//通过负载均衡机制从事件循环线程池中选择一个工作线程出来
EventLoopPtr worker_loop = server->worker_threads.nextLoop(server->load_balance);
if (worker_loop == NULL) {
worker_loop = server->acceptor_loop;
}
++worker_loop->connectionNum;
//通过调用newConnEvent完成的工作有:
//1.将connio加入到工作线程的记录表中,
//2.以connio构造一个通道,在构造通道时为connio设置好了可读、可写、关闭回调函数
//3.将io放入到工作线程的epoll中监听可读事件
worker_loop->runInLoop(std::bind(&TcpServerEventLoopTmpl::newConnEvent, connio));
}
主要工作首先将connio从accept loop中剥离,因为并没有将connio放入到epoll中进行监听,所以这里的剥离仅仅是将connio从accept loop的记录表中删除,然后根据负载均衡策略从线程池中选择一个工作线程,再在该工作线程中执行newConnEvent()函数,newConnEvent()函数作用见上面代码注释,为connio设置的可读回调为Channel类的on_read(),设置的可写回调函数为Channel类的on_write(),记住这两个回调函数,后面还需要使用到他们。newConnEvent()函数将connio放入epoll中等待可读事件,所以接下来我们就应该看看on_read()函数,探究客户端发送数据来了服务端做了哪些操作。
10. on_read()
当有可读事件时,经过epoll、事件循环一系列的调用最终会调用到Channel.h中的on_read()函数(此处省略10000字,如果你真想找到这条调用流程,我还是简单给一个思路,newConnEvent函数创建通道时已经将io中的各种回调指向了Channel类中的函数,调用startRead函数将io放入epoll中进行监听时,将io的cb指针指向nio.c的hio_handle_events函数,通过分析事件循环代码可知,当有IO事件发送的时候首先调用的是io的cb指针指向的函数即hio_handle_events函数,再在hio_handle_events函数中根据不同的事件类型调用io中指针指向可读、可写、关闭等回调函数,所以最终调用到了Channel.h中的on_read()函数)。那么我们看看on_read函数
static void on_read(hio_t* io, void* data, int readbytes)
{
Channel* channel = (Channel*)hio_context(io);
if (channel && channel->onread) {
Buffer buf(data, readbytes);
channel->onread(&buf);
}
}
再看看onread函数,噢,它是一个函数对象
std::function<void(Buffer*)> onread;
它是在newConnEvent函数中被设置的,代码如下
static void newConnEvent(hio_t* connio) {
TcpServerEventLoopTmpl* server = (TcpServerEventLoopTmpl*)hevent_userdata(connio);
...
const TSocketChannelPtr& channel = server->addChannel(connio);
channel->status = SocketChannel::CONNECTED;
channel->onread = [server, &channel](Buffer* buf) {
if (server->onMessage) {
server->onMessage(channel, buf);
}
};
channel->onwrite = [server, &channel](Buffer* buf) {
if (server->onWriteComplete) {
server->onWriteComplete(channel, buf);
}
};
channel->onclose = [server, &channel]() {
EventLoop* worker_loop = currentThreadEventLoop;
assert(worker_loop != NULL);
--worker_loop->connectionNum;
channel->status = SocketChannel::CLOSED;
if (server->onConnection) {
server->onConnection(channel);
}
server->removeChannel(channel);
// NOTE: After removeChannel, channel may be destroyed,
// so in this lambda function, no code should be added below.
};
...
}
在onread()其实就是调用TcpServerEventLoopTmpl类的omMessage,而该函数在main()中定义,代码如下:
可以看到,onMessage函数先将接收的数据打印出来,然后调用write()将接收的数据发送出去,所以接下来我们应该看看channel->write()。
11. channel->write()
int write(const void* data, int size) {
if (!isOpened()) return -1;
return hio_write(io_, data, size);
}
int write(Buffer* buf) {
return write(buf->data(), buf->size());
}
看来主要工作的函数是hio_write(),该函数在nio.c中,看看其代码
int hio_write (hio_t* io, const void* buf, size_t len) {
if (io->closed) {
hloge("hio_write called but fd[%d] already closed!", io->fd);
return -1;
}
int nwrite = 0, err = 0;
hrecursive_mutex_lock(&io->write_mutex);
#if WITH_KCP
if (io->io_type == HIO_TYPE_KCP) {
nwrite = hio_write_kcp(io, buf, len);
// if (nwrite < 0) goto write_error;
goto write_done;
}
#endif
//判断写队列是否为空,如果不为空,不能直接写,要先处理写队列中的数据,否则会照成数据乱序
//所以当队列中有数据时,直接将本次的数据加入到队列尾
if (write_queue_empty(&io->write_queue))
{
try_write:
//队列为空直接发送数据
nwrite = __nio_write(io, buf, len);
// printd("write retval=%d\n", nwrite);
if (nwrite < 0)
{
//如果是EAGAIN,那么需要之后再尝试发送,所以这里先入队列
err = socket_errno();
if (err == EAGAIN || err == EINTR) //EAGAIN:缓冲区已满 EINTR:系统调用被中断
{
//写失败将数据放入队列
nwrite = 0;
hlogw("try_write failed, enqueue!");
goto enqueue;
}
else
{
// 出现错误,关闭连接
// perror("write");
io->error = err;
goto write_error;
}
}
if (nwrite == 0) {
goto disconnect;
}
//如果一次性发送完成,直接就返回,不需要使用写队列
if (nwrite == len)
{
goto write_done;
}
enqueue:
hio_add(io, hio_handle_events, HV_WRITE);//注册写事件
}
//如果没有一次性发送完成,需要将数据加入写队列
if (nwrite < len)
{
if (io->write_bufsize + len - nwrite > io->max_write_bufsize) {
hloge("write bufsize > %u, close it!", io->max_write_bufsize);
io->error = ERR_OVER_LIMIT;
goto write_error;
}
offset_buf_t remain;
remain.len = len - nwrite;
remain.offset = 0;
// NOTE: free in nio_write
HV_ALLOC(remain.base, remain.len);
memcpy(remain.base, ((char*)buf) + nwrite, remain.len);
if (io->write_queue.maxsize == 0) {
write_queue_init(&io->write_queue, 4);
}
write_queue_push_back(&io->write_queue, &remain);
io->write_bufsize += remain.len;
if (io->write_bufsize > WRITE_BUFSIZE_HIGH_WATER) {
hlogw("write len=%u enqueue %u, bufsize=%u over high water %u",
(unsigned int)len,
(unsigned int)(remain.len - remain.offset),
(unsigned int)io->write_bufsize,
(unsigned int)WRITE_BUFSIZE_HIGH_WATER);
}
}
write_done:
hrecursive_mutex_unlock(&io->write_mutex);
if (nwrite > 0) {
__write_cb(io, buf, nwrite);
}
return nwrite;
write_error:
disconnect:
hrecursive_mutex_unlock(&io->write_mutex);
/* NOTE:
* We usually free resources in hclose_cb,
* if hio_close_sync, we have to be very careful to avoid using freed resources.
* But if hio_close_async, we do not have to worry about this.
*/
if (io->io_type & HIO_TYPE_SOCK_STREAM) {
hio_close_async(io);
}
return nwrite < 0 ? nwrite : -1;
}
hio_write()函数的大致流程在代码中有详细注释,需要强调的是当写队列不为空或者一次没有将发送完时,需要将数据放入写队列,并添加可读事情到epoll中,当发送缓冲区有空间时就会触发可写事情,事件循环调用写函数将写队列中的数据发送出去。
至此,tcpServer_test流程介绍完毕
收获
//TODO 深入挖掘学习到的知识及收获
1.使用网络库libhv开发程序
熟悉了libhv网络库,可以利用它来开发跨平台的程序,而不用自己造基础轮子。事件循环是该库的核心,事件包括IO事件、定时器事件、空闲事件,利用该事件循环不只是开发网络程序,还能利用它来开发并发程序。libhv库中还封装一些好用的模块,比如说字符串操作、日志文件、线程、配置文件操作等。
2.多进程/多线程编程模式
常见的多进程/多线程编程模式有
one thread per connection(
每个连接一个线程)multi-acceptor-processes(
多accept进程模式)multi-acceptor-threads(
多accept线程模式)one-acceptor-multi-workers(
一个accept线程+多worker线程)
下面3个都是使用非阻塞IO多路复用机制,每个进程/线程都有运行一个事件循环(hloop_run
),accept
请求,将连接上来的fd加入到IO多路复用中,监听读写事件。
3.lambda表达式的好处
lambda表达式的好处之一,能保存lambada定义时的上下文。//TODO lambada总结笔记
4.原子操作std::atomic
以前只是在书上看到过原子操作,但实践中并没用使用,多线程访问同一个变量时,我一般是使用锁来保护,但是使用锁的性能比原子操作要低得多。
5.将类的成员函数作为线程函数的方法
我们知道类的成员函数第一个参数其实是this,这是编译器默认给加上的。在创建线程的时候需要在创建线程的函数中手动填写this。
6.
未完......