来自:http://blog.csdn.net/NewNebuladream/archive/2009/08/12/4437888.aspx

  C++扫盲系列--第一个服务器程序 收藏

关于需求

         进行程序开发,对于需求的把握是至关重要的。可以说,我之前没有任何开发服务器程序的经验,因此首先在对于需求的把握上出现了问题。

本程序的功能:

Linux环境下实现一个服务器程序,通过管道,从本地的客户端读取数据,然后进行解析、组包,之后发送POST给远程的服务器程序。最后,读取远程服务器发送回来的响应,并打印在屏幕上。

我的第一个程序就是仅仅考虑了上述的基本需求,然后编写了一个单线程同步程序实现。但是,这样的程序并不能够满足一个服务器程序的需求。

主要问题出现在如下方面:

1.       服务器程序代码要保证绝对的健壮,不能因为程序或数据的异常而退出。

2.       程序要保证尽量减少数据包的丢失。

3.       程序对于管道的监听应该采用阻塞的模式。

4.       对于管道传递过来的大量数据进行快速的处理。

5.       在保证整个服务器程序安全性的基础上,要尽量增大整个系统的吞吐量。

如果同时的去考虑上述的问题,那么原先的程序结构就要进行调整了。

管道通信

         我的程序当中采用了FIFO进行本地程序之间的通信。对于FIFO的应用,我们需要清楚如下事实:

1.       open()read()都可以阻塞程序的运行。到服务器端调用open函数时,如何此时没有客户端连接则程序会发生阻塞,直到第一个客户端程序与服务器程序建立管道连接为止。在建立连接之后,如果管道当中没有数据可读,则read函数会发生阻塞直到管道当中有数据可读为止。如果无客户端连接,read不会发生阻塞,并且其返回值为0

上面这一条要特别的注意,因为当read返回值为0的时候,我们就不能以read阻塞的方式来进行监听了,倘若程序当中不存在其它的阻塞方式,那么整个服务器程序就会陷入循环调用,他会严重的浪费处理器资源。具体情景如下:

server_fifo_fd = open(SERVER_FIFO_NAME,O_RDONLY);

while(1)

{

     read_res = read(server_fifo_fd, &my_data, sizeof(my_data));

cout << read_res << endl;

if(read_res > 0)

{

          ...

     }

}

一旦出现无客户端连接的状态,这个程序将无法进行进入“空转”状态,其表现为不断的打印0.这样,我们就会因为一个“什么也不做”的循环而白白浪费处理器资源。

 

那么,我们又应该如何解决上述问题呢?请看下面代码:

server_fifo_fd = open(SERVER_FIFO_NAME,O_RDONLY);

while(1)

{

     read_res = read(server_fifo_fd, &my_data, sizeof(my_data));

     cout << read_res << endl;

     if(read_res > 0)

     {

         ...

     }

     else

     {

         close(server_fifo_fd);

         server_fifo_fd = open(SERVER_FIFO_NAME,O_RDONLY);

     }

}

 

如何发现没有客户端连接在管道上,我们就关闭管道,然后再打开,通过open来再次让程序进入阻塞状态。

io_service

根据我现在的理解,io_service就是一个任务调度机。我们将任务交给io_service,他负责调度现有的系统资源,把这些任务消费掉。对于不同种类的任务,可以将它们分配给不同种类的调度机来分别执行,这样即便于管理,又有利于增加程序的吞吐量。

         在我的程序当中,大体存在两个独立的任务:

1.       从管道读取数据。

2.       与远程服务器之间的通信。

这样,可以建立两个调度机来管理这两个相对独立任务。为了运行这两个调度机,我们首先需要将它们分别绑定在两个线程上。这里还不得不提到一个问题:boost库所提供的io_service在没有任务执行的时候会自动的退出。而boost库当中标准的线程绑定方式如下:

     boost::thread *t_read = new boost::thread(boost::bind(

              &boost::asio::io_service::run, io_service_read));

此时,相当于开始运行了io_service。也就是说,如果在它之前,没有对io_service进行初始的绑定,那么程序就会自行的退出。再有就是如果在运行的过程当中,io_service处理完了其本身的所有任务,而服务器程序又不会新建一个调度机,那么该程序也将死掉。为了解决上述问题,我们需要对于io_service绑定一个资源消耗低而且会永远执行下去的程序。

boost::asio为我们提供的定时器可以满足上述的需求,我们可以创建一个循环定时器作为io_service的初始化任务。代码如下:

class io_clock

{

private:

     boost::asio::deadline_timer timer;

public:

     io_clock(boost::asio::io_service &io):

         timer(io, boost::posix_time::hours(24))

         {

              timer.async_wait(boost::bind(&io_clock::no_dead,this));

         }

     void no_dead()

     {

         timer.expires_at(timer.expires_at()+boost::posix_time::hours(24));

         timer.async_wait(boost::bind(&io_clock::no_dead, this));

     }

};

 

    这段代码是一段经典的定时器异步程序。关于异步程序的问题,过一会再讨论。

    下面继续讨论io_service。现在,我们已经知道什么是调度机了,并且计划在系统当中运用两个调度机,一个处理管道读取,另外一个运行远程通信。大致流程是,从管道读取数据,之后进行解析,将解析后的数据传入另外一个调度机当中实现数据包的组成、发送以及接收等操作。

    现在一个新的问题产生了:如何实现两个调度机之间的数据通信呢?

    这里就涉及到io_service当中的post方法了。它可以实现将一个函数绑定到一个正在运行的io_service之上。这样,只要实现每当io_service1产生了一个数据就可以通过post的方式传递给io_service2来进行继续的执行。

请看下面这段代码:

         while(1)

         {

              //读取数据

              read_res = read(server_fifo_fd, &my_data, sizeof(my_data));

              cout << read_res << endl;

              if (read_res > 0)

              {

                   //对于读取后的数据进行解析

for (int i = 0; i < my_data.number; i++)

                   {

                       std::string Furl;

                       Furl = my_data.package[i].url;

 

                       post = my_data.package[i].post;

 

                       std::size_t first_sign = Furl.find("//");

 

                       url = Furl.substr(first_sign + 2);

                       std::size_t second_sign = url.find("/");

                       url = Furl.substr(first_sign + 2, second_sign);

 

                       path = Furl.substr(first_sign + second_sign + 2);

                       //若为域名,则需要先解析为IP地址。

                       if (url.find("www") != url.npos)

                       {

                            host = gethostbyname(url.c_str());

                            char **pptr;

                            char str[32];

                            pptr = host->h_addr_list;

                            inet_ntop(host->h_addrtype, *pptr, str, sizeof(str));

                            url = str;

                            cout << url;

                       }

                       client Client (*socket_io,url, path, post);                                              io->post(boost::bind(&client::process,&Client,url));

                   }//end for

              }//end if

              else

              {

                   close(server_fifo_fd);

                   server_fifo_fd = open(SERVER_FIFO_NAME, O_RDONLY);

              }

         }

    上面程序当中,红色的部分是不安全的(在异步的情况下)。在掌握了io_service的基本原理之后,这个问题就变得简单易懂了。上面这段程序是由io_service1运行的,其将接收来的地址解析之后就抛给了io_service2来继续处理。倘若io_service2还没有处理完client当中的process方法时本次for循环就结束了,那么Client就会被析构。这时io_service2当中的任务实际上已经被析构掉了,那么io_service2执行到这段任务的时候就会引发非法内存的访问!!!

      异步程序

         发生上述问题的一个前提是

io->post(boost::bind(&client::process,&Client,url));的运行不会发生阻塞。

         如果process是普通的同步程序,这个问题就不会发生。但同步的阻塞会影响程序并发的行为,这样就可能降低系统的吞吐量。

         为了程序运行的非阻塞实现,我们采用异步程序设计的方式。同步IO与异步IO的本质区别是:同步IOblock当前的调用线程,而异步IO则允许发起IO请求的调用线程继续执行,等到IO请求被处理后,会通知调用线程。关于异步IO的介绍详情可以参考http://www.ibm.com/developerworks/cn/linux/l-async/ 

 

         我总结:写异步程序的关键就是始终提醒自己,这段代码在什么时候结束你永远不知道!

 

         这样,如果我们将网络任务编写成异步程序就会实现多个线程并发的执行各自的网络任务,而这些并发的线程则由io_service进行统一的调度处理。

shared_ptr内存管理机制

         从上面的模型可以看出,client实际上是由io_service1创建,然后连同数据一起抛给io_service2去执行。这样就产生了一个问题,如果通过new的方式来产生一个client对象,那么这个对象应该在什么时候销毁呢?答案是在process方法执行结束后销毁。但是process方法执行什么时候执行结束?只有它自己知道。除非用对象调用自己的析构函数来销毁掉自己。如果采用上述方式,我们就需要在异步程序结束之前,调用这个类本身的析构函数。但是,假如异步程序中途产生异常而没有执行结束,那么这段内存空间又由谁来释放呢?为了解决这个问题,我们用shared_ptr来管理client对象。这样我们先声明一个shared_ptr指针,然后更改client的声明 class client:public boost::enable_shared_from_this<client> client内部异步调用的this指针变成shared_from_this

      这样,异步调用结束的时候,他的指针计数也将变为0,这段内存空间就被自动的析构了。

其它问题

Linux操作系统本身限制:打开文件的数量上限为1024,倘若不对网络并发进行限制,很可能因为打开文件数量达到上限而被系统拒绝进行socket服务。那么,如何控制并发在一定的范围之内,从而避免数据包的丢失?

另外一个就是:本程序吞吐量限制的瓶颈是网络并发程序的速度问题,本地管道传送的速度相比之下要快得多了,这也是为什么在程序当中,我并没有把管道通信函数写成异步程序的原因。倘若网络并发程序的问题可以得到完美的解决,那么这个程序的代码结构恐怕还要发生如下两处改变:

1.       程序暂时采用固定长度的数据包发送,这在一定程度上降低了管道通信的速度。

2.       管道数据的读取与解析在这里是作为一个同步串行程序来执行的,可以进行如下两种方案的改进:

a.       将解析程序写入client类当中,交给io_service2执行。

b.       将管道通信函数写成异步形式。

总结

短短的300多行代码当中却集中了管道、操作系统原理、线程池管理、内存管理、智能指针、异步IO、多线程……等思想。作为初学者来说,这个程序使我学会了很多,但是也暴露了很多问题:

1.       对于异步与多线程的概念陌生,导致在编程的过程当中发生了低级的访问非法内存错误。

2.       在编写多线程异步程序时,要改变思路,不能陷入同步程序的思维模式当中。

3.       在解决问题之前,尽量弄清楚要解决的问题到底是什么,即需求一定要做好!对于服务器程序运行需求的不充分理解,导致了我始终不清楚什么才是符合要求的程序!

4.       如何更有效的解决问题?

所谓“有效”就是用最短的时间以适当的方案解决问题。

如果没有足够的基础,那么无疑会浪费解决问题的时间。在写这个程序之前,我的脑海当中对于多线程异步调用的概念理解很模糊,更别说按照这个思想来写程序了。但是,如果等到了解了所有知识之后再去解决问题,时间成本可能又会很高。那么,如何在两者之间做一个权衡就是快速解决问题的关键所在。

我觉得,首先应该明确问题到底是什么。要尽量考虑一切可能出现的问题,然后再考虑如何简化问题,先做什么,后做什么。这样,就不至于由于需求不明确而导致对于问题本身的曲解。例如,假设我的服务器程序仅仅用来提供对几台主机的服务,那么这个程序的结构就用不着这么复杂了。所以,在这种情况下,数据通信量的因素就必须纳入到问题需求的考虑范围之内。

总结一句话:在做需求的时候,一定要弄清所有决定问题本质的关键性因素后再开始制定解决问题的计划,因为忽略了这些因素后,问题的本质就发生了变化,就不再是原来的问题了。

对于解决问题可能会用到的知识至少要有一个概念上的清晰认识,然后再开始解决问题。否则,解决的过程就会变成一种盲目的探路,虽然最终问题也会得到解决,但是会因为盲目性而导致解决问题中走弯路,浪费不必要的时间。