Linux服务器开发初步
服务器开发需要考虑的内容很多,比如服务器的架构、稳定性、性能以及负载能力等等。
事实上,在开发服务器的过程中,需要综合考虑各种因素,比如就客户端连接时间较短却又比较频繁的服务器(例如HTTP服务器)而言,在可选的服务器结构中,预先派生进/线程的结构就要比并发式结构高效,这一点将在后续的文章中对其进行详细的介绍。
然后就是服务器实现方面的细节,比如是否需要支持跨平台的能力、采用什么样的开发语言和开发工具、如何提高服务器系统的性能。所有的这些问题都需要在服务器的定义与设计的过程中作出充分的考虑。
其实,无论是Windows服务器,还是Linux服务器,它们之间都有共同的特点。
首先就是后台运行,目前,绝大多数服务器都是后台运行的,这是因为服务器的主要任务是给客户端提供所请求的服务,通常情况下是不需要与用户进行界面交互的,用户只需要能够启动服务、暂停服务或者停止服务就可以了,因此,服务器没有必要去占有一个终端会话(或者说是拥有一个可视化的用户界面);
其次,由于服务器是后台运行的,它并没有一个可视化的用户界面,所以服务器运行时所需的参数就只能通过文件[1]读入,然后根据从文件中读入的数据作不同的处理;
再次,由于服务器的后台运行,它无法通过界面将运行状态以及一些必要的处理结果显示给用户,因此,它需要将这些信息写入一个文件[2],以便在服务器出现问题的时候,用户能够根据该文件中的内容对服务器的故障进行诊断;
最后,还是与服务器的后台运行有关,对于计算机的用户来说,服务器并不是一个需要经常交互的程序,与一般的应用程序相比,在服务器设计的过程中,应该更多地考虑服务器占用系统资源的问题,这里所说的资源包括CPU、IO以及存储器资源。对于Windows服务来说,这点尤为重要,因为Windows服务很有可能就是安装在某一个用户的机器上,而不是特定的Windows服务器上。试想,如果某个Windows服务占用了过多的系统资源,那么该系统的用户就很有可能无法正常地完成其他的工作。
上面总结了各种服务器所共有的特点,下面将对这些共有特点的设计与实现进行详细的描述,并对Windows服务器与Linux服务器之间的差别进行必要的说明。
一、 后台运行
在Linux环境下,要实现服务器的后台运行非常简单,只需要在服务器程序文件名后面加上“&”符号就可以了。例如某服务器程序的文件名是test_server,那么启动该服务器并使其后台运行的方法就是在提示符下输入“test_server&”。假设现在有一个test_server服务器,其代码如下:
#include
int main (int argc, char *argv[])
{
while (1)
{
// code to serve the client
}
return 0;
}
在使用“test_server&”运行该程序以后,系统会自动回到提示符状态而不占用任何终端(如果直接使用程序名来启动程序,那么系统会因为while死循环而占用终端,直到将程序进程杀死)。我们可以使用ps命令来查看程序是否已经在后台运行,我们可能会获得类似下面的提示信息:
root 1417 1358 99 14:47 tty1 00:12:10 ./test_server
为什么在这个简单的程序中需要使用while死循环呢?原因有两个,首先,如果不是死循环,那么程序刚运行就会退出,我们根本就看不到它是否已经运行了,更不用说去检查它是否已在后台运行;其次,这个程序结构是服务器结构的一种抽象,大多数服务器的数据接收、处理和发送等操作都是在这个while死循环中进行的,直到获得一个信号或指令后,服务器程序才会退出。
遗憾的是,没有一种让人感觉比较专业的方法能够让服务器程序退出,我们可以用kill命令将服务器进程杀死,这种做法似乎有点野蛮,但也不失为一种不错的方法。在稍后的介绍中,我们会用另外一种看上去感觉更加专业的办法来解决这个问题。
另一种使服务器程序后台运行的方法是使用守护进程(daemon process)。要将服务器程序作为守护进程运行,通常的做法是先定义一个守护进程初始化函数,然后在main函数中调用这个初始化函数。一般情况下,该守护进程初始化函数可以定义如下:
void InitDaemon()
{
pid_t pid;
if ((pid = fork()) != 0)
exit(0);
setsid();
if ((pid = fork()) != 0)
exit(0);
chdir(“/”);
umask(0);
}
程序首先调用fork,并让父进程退出,子进程继续运行。由于子进程是由父进程通过fork系统调用派生的,因此子进程继承父进程的进程组号,但它拥有自己的进程号;然后调用setsid创建一个新的session[3],子进程是该session中唯一的进程,并且是该session的leader(或者说是creator),同时也是新创建的进程组的leader。在调用setsid以后,调用进程将不再拥有控制终端。第二次fork的目的在于,确保当进程打开终端设备时,也无法获得控制终端,这是因为由session leader派生的子进程一定不是session leader,也就无法获得控制终端;接下来的chdir系统调用是更改守护进程的工作目录,假设守护进程是在vfat文件系统中启动的,如果没有使用chdir来更改守护进程的工作目录,那么在守护进程进入后台运行后,此vfat文件系统就有可能无法卸载(unmount)。事实上,在Linux系统中,即使使用了chdir来更改守护进程的工作目录,启动该守护进程的文件系统仍然无法卸载。在实际使用中,chdir更多地用于满足服务器本身的处理需求;最后的umask(0)是为了在守护进程创建自己的文件时,文件权限位不受原有文件创建掩码的权限位的影响。
在定义了守护进程初始化函数以后,只需要在必要的时刻(一般是main函数中)调用该初始化函数就可以了,例如:
int main (int argc, char *argv[])
{
InitDaemon();
while (1)
{
}
return 0;
}
前面已经提到,要使处于后台运行的服务器进程退出,可以用kill命令将服务器进程杀死,这对于使用守护进程初始化函数实现后台运行的服务器程序同样有效。这种做法看起来似乎有点野蛮,但不失为一种使服务器进程退出的好方法。但如果采用这种方法,那么在设计服务器程序的时候,我们应该使其具有捕获某种信号(这个信号通常是由kill命令指定的)的能力,并在捕获了该信号以后能够完成一些服务器进程退出前的扫尾工作,比如释放内存、关闭文件描述字等,否则很有可能造成资源泄漏。
举例来说,我们可以使用kill命令向某个进程发出指定的信号,这个信号可以通过参数来指定,当然我们可以不指定这个参数,这样的话kill命令会默认地向进程发出SIGTERM信号。现在我们要做的是,在服务器程序中加入对SIGTERM信号的处理函数,以便在获得该信号后执行一些必要的处理。我们可以使用类似下面的代码来实现这样的功能:
#include
void handle_term_signal (int sig)
{
// 此处填写必要的处理逻辑
}
int main (int argc, char *argv[])
{
// . . .
signal (SIGTERM, handle_term_signal);
// . . .
return 0;
}
然后使用“kill –SIGTERM 进程号”或“kill 进程号”命令向指定的服务器进程发送SIGTERM信号,服务器进程接收到这个SIGTERM信号后,就会执行handle_term_signal函数。由于SIGTERM信号是可以被阻塞和拦截的,这一点与SIGKILL信号是不同的,因此在服务器程序中,我们需要捕获的是SIGTERM信号,而不是SIGKILL信号。
需要说明的是,在Linux系统中,进程接收到SIGTERM信号后并不会退出,要让运行中的进程退出,还需要向其发送SIGKILL信号。也就是说,首先向服务器进程发送SIGTERM信号,使其完成退出前的收尾工作,然后向其发送SIGKILL信号,将进程杀死。
为了操作的方便,可以编写一个很简单的shell程序来完成上面所说的操作。打开vi,输入以下的shell命令,然后以killit文件名保存:
kill –SIGTERM $1
sleep 2
kill –SIGKILL $1
这样的话,只需要使用“killit 进程号”命令就可以让后台运行的服务器进程退出了。在上面的shell程序中,两个kill命令间使用了sleep命令,这是为了确保服务器进程在接收到SIGKILL信号前,已经完成了必要的收尾工作。
值得注意的是,如果在函数handle_term_signal()中加入exit()函数调用,那么当进程接收到SIGTERM信号后,会自动退出,而不需要再次使用kill –SIGKILL命令。
当然,我们可以不使用kill命令来使服务器进程退出,而使用命令行参数这样一种看上去相对专业的方法来实现。假如服务器程序文件名为“test_server”,那么我们可以使用“test_server start”来启动服务器;用“test_server stop”来停止服务器;而用“test_server restart”来重新启动服务器。实现这种做法的基本思路是:对命令行参数进行判断,如果是start,那就看是否已经有一个服务器进程在运行,如果是的话,则提示说服务器已经运行,否则就启动服务器;如果命令行参数是stop,那么就看服务器进程是否处于运行状态,如果是的话,则服务器进程退出,否则就提示说服务器还没有运行;如果命令行参数是restart,那么首先将原服务器进程退出,然后再启动。这样做既可以使我们的服务器程序看上去更加专业,又可以避免服务器的多次重复启动所造成的某些资源冲突以及资源浪费。基本的流程大致如下图所示:
![](http://www.sunnycrystal.net/image/usr_images/Documents/Linux%E6%9C%8D%E5%8A%A1%E5%99%A8%E5%BC%80%E5%8F%91%E5%88%9D%E6%AD%A5-1.jpg)
图一 带参服务器程序启动流程
那么服务器程序在启动的时候,应该如何判断是否已经有一个进程正在运行呢(也就是图一中两个菱形框的判断条件,通常将这样的问题称为程序的二重启动问题)?解决这个问题最直接的办法是使用进程通讯,通过进程通讯,待启动的服务器进程会首先查询某些标志位或试图与已经运行的服务器进程通讯。如果在全局域中设置了标志位,或者能够成功地与已经运行的服务器进程通讯,则表示服务器进程已经启动,无需再次启动;否则就启动服务器进程。Linux下实现进程通讯的方法很多,socket、管道、互斥锁以及共享内存等,都可以实现进程间通讯。我们可以使用共享内存来实现,这是因为共享内存除了可以体现标志位以外,还能够保存一些有用的数据,比如进程的已运行服务器进程的pid。这样的话,在启动服务器进程的时候,首先判断共享内存中的标志位,如果标志位存在,则表示已经有一个服务器进程在运行,进而再判断当前的服务器程序参数是什么,如果参数为“start”,那么显然是要让当前待运行服务器进程直接退出的;如果参数为“stop”,那么就从共享内存中获取已运行服务器进程的pid,然后使用kill函数向其发送SIGTERM信号,已运行服务器进程在捕获了这个SIGTERM信号后,立即转入handle_term_signal()函数进行进程退出前的扫尾工作,然后进程退出。假设我们使用共享内存来解决二重启动与进程退出问题,那么图一中描述的流程可以细化为:
![](http://www.sunnycrystal.net/image/usr_images/Documents/Linux%E6%9C%8D%E5%8A%A1%E5%99%A8%E5%BC%80%E5%8F%91%E5%88%9D%E6%AD%A5-2.jpg)
图二 带参服务器启动流程(细化图)
注意,上图中没有画出共享内存的具体实现流程,如共享内存申请失败、共享内存读写失败的处理流程。
由此可见,Linux下服务器程序的开发比Windows下要复杂许多,它需要开发人员更多地考虑服务器的启动、停止和重启的细节问题,不仅如此,开发人员还需要对Linux环境下C语言的高级应用有一定的了解。
二、 配置文件
前面已经提到,由于服务器程序没有用户界面,所以用户也就无法通过界面来设置服务器运行所需要的参数。这一问题可以使用配置文件来解决,服务器程序只要读取配置文件中的信息,就可以获得所需要的参数。
通常情况下,服务器程序只有在启动的时候才读取配置文件,因此,如果用户修改了配置文件的内容,要想使得所做的修改生效,就必须重新启动服务器程序。修改了配置文件以后需要重新启动服务器的另一个原因是,在服务器程序的运行过程中,某些参数是需要被多次使用的,如果在使用的过程中修改了这些参数的值,就有可能影响服务器程序的运行逻辑。
常用的配置文件格式有两种,一种是INI形式的文件,另一种是XML格式的。配置文件使用什么样的格式其实并不重要,只要服务器能够从中正确地读取数据就可以了。当然,配置文件一定是文本文件,这是为了方便服务器的管理员对配置文件进行修改。如果设计的系统能够提供配置文件的编辑程序,那么,配置文件也可以是二进制文件。
在Linux系统中,使用C语言读写INI格式文件或者是XML文件都不是件简单的事情,开发者可以使用第三方提供的开发库,但就我目前的情况,我使用的是INI格式文件,并且为INI格式文件中数据的读取编写了一套函数库。除非第三方的开发库做得非常优秀、可信度非常高,否则尽量不要使用,这是因为你无法控制他人所编写的程序中的错误率,一旦程序的运行出现问题,调试他人的程序将会是件令人头痛的事情。在Windows系统中,读写INI文件非常简单,开发者不需要自己去编写文件解析程序,使用Windows API中的GetPrivateProfileString、WritePrivateProfileString等函数就可以方便地读写INI文件;读写XML文件也不会太难,.NET Framework为XML文件的操作提供了很好的支持。在32位的Windows系统中,应该尽量将配置信息写在系统注册表中以供服务器程序读取,而不要使用INI文件,这是Windows系统中应用程序读写配置信息的最佳操作。如果所设计的服务器系统需要与16位Windows系统中的某些程序兼容,那么就可以根据情况来决定是否使用INI文件。
三、日志文件
日志文件是服务器系统的重要组成部分。目前出现的绝大多数服务器都有自己的日志文件。由于服务器程序的后台运行特性及其特殊的工作方式,它无法将一些过程、状态以及结果信息显示在屏幕上,日志文件就成为了服务器程序记录数据的主要方式。由于日志文件中记录了服务器程序处理过程、工作状态和日期等关键数据,因此,日志文件是服务器系统错误跟踪的主要依据,如果服务器程序在运行的过程中出现问题,那么系统管理员就可以根据日志文件中的数据描述确定问题的来源,进而解决问题,使服务器正常工作。
在Linux环境下开发服务器的过程中,程序员可以根据实际情况在服务器程序的适当位置添加记录日志信息的代码,这样,当服务器程序运行到该位置时,会自动地向指定的日志文件输出信息。下面的代码段试图在程序出现异常后将异常信息输出到日志文件:
void MyServerApp::Main (int argc, char *argv[])
{
// . . .
try
{
// . . .
}
catch (MyServerException &ex)
{
gAppLog->WriteLog (LOGLEV_ERR, “Exception raised: %s/n”, ex.Message);
}
// . . .
}
现在,我们谈谈如何实现日志文件的写入处理,也就是如何实现上例中的gAppLog->WriteLog函数[4]。不难发现,WriteLog函数是一个可变参的函数,参数的设置可以根据不同情况进行设定。例如,上面的WriteLog函数仅向日志文件写入了日志条目级别和必要的信息字符串,服务器系统的设计者同样可以修改这个WriteLog函数,使得该函数还能够向日志文件输出服务器名称、日志条目写入时间等信息。在Linux系统中,设计可变参的函数其实很简单,只要在函数声明的时候使用省略符格式,在函数定义的时候使用va_list相关的宏来实现具体的操作即可。下面的例子演示了WriteLog函数的具体实现,真正的日志输出函数应该根据服务器系统的具体需求情况进行定义。
int WriteLog (int level, char *fmt, . . .)
{
char loglev_str[512];
FILE *fp;
va_list args;
memset(loglev_str, 0x00, sizeof(loglev_str));
fp = fopen (“serversystem.log”, “a+”); // 此处第一个参数指定日志文件名
// 第二个参数使用a+模式打开文件,保证日志的正确写入
if (NULL == fp) return -1;
switch(level)
{
case LOGLEV_OK: strcpy(loglev_str, “[OK ]”); break;
case LOGLEV_ERR: strcpy(loglev_str, “[ERR ]”); break;
case LOGLEV_WAR: strcpy(loglev_str, “[WARN]”); break;
default: break;
}
va_start(args, fmt);
fprintf (fp, “%s”, loglev_str);
vfprintf (fp, fmt, args);
va_end(args);
fclose(fp);
return 0;
}
在上面的代码中,WriteLog函数一味地向serversystem.log文件写入信息,时间一长,势必会导致日志文件容量的无限期增加,这是一个非常严峻的问题,过大的日志文件可能占用磁盘的大部分有效空间,从而造成服务器系统因为没有足够的磁盘空间而出现异常甚至崩溃。由于服务器程序启动以后很少需要人为的干预,日志文件容量无限期增加这一问题很容易被服务器系统管理员忽视,而作为管理员来说,要每隔一定的时期去为服务器清理日志文件也是一件麻烦的事情,并且稍不小心就有可能影响服务器系统的正常运行,这些问题对于使命关键的服务器(例如,大型商务系统的核心服务器等)来说是无法容忍的。由此可见,服务器系统需要一个日志管理机制,为服务器系统日志的管理提供一个中心位置,该日至管理机制至少需要两个功能:①对过大的日志文件进行备份,并重写(overwrite)日志文件;②删除过期的备份日志文件。
服务器的日志管理机制可以是当前服务器系统的一个子系统,也可以是一个独立的服务器系统,这可以根据所设计的服务器系统的规模来决定。日志管理机制没有必要时时刻刻处于对日志的清理状态,因为日志管理机制的运行也需要占用系统资源,势必会影响主服务器系统的运行效率。通常的做法是,每天或每隔几天,选择一个服务器访问率相对较小的时间(比如午夜或凌晨)进行系统日志的清理工作。如果服务器系统在此期间内无法停止运行,那么日志处理模块还需要提供日志写入缓冲和日志文件锁定机制,确保日志信息在日志管理机制对日志文件进行清理时也能正确写入。
至此,对服务器程序三大主要特点的基本介绍就告一段落。本文首先对服务器程序的特点作了简要的介绍,引出了服务器程序的最主要的特点:后台运行,这一特点也就决定了服务器程序配置文件和日志文件存在的必要性;然后,本文对Linux环境下服务器程序的后台运行等特点作了详细的描述,并提出了一些设计和实现的方案。限于篇幅,本文无法将实现的每个细节都阐述清楚,比如上面所说的日志写入缓冲和日志锁定机制等,但在后续的Linux服务器开发文章中,笔者会尽可能地阐明其中的具体细节问题,使得读者对Linux服务器程序的实现过程有更深一步的了解。
[1] 此文件就是通常所说的服务器配置文件
[2] 此文件就是通常所说的服务器日志文件
[3] 确切地说,应该是在调用setsid时,如果调用进程不是进程组的leader,那么setsid函数将会创建一个新的session。但在此处,派生的子进程并非进程组的leader,因此调用setsid将会创建一个新的session。
[4] 为说明方便,今后用WriteLog函数代替此日志文件写入函数
转载地址: