【nginx】服务器程序框架

服务器程序目录规划、makefile编写

一、信号高级认识范例

在触发SIGUSR1信号并因此sleep的10秒种期间,就算你多次触发SIGUSR1信号,也不会重新执行SIGUSR1信号对应的信号处理函数,而是会等这个信号处理程序执行完了,把那些信号为一次执行一次信号处理程序。但是,如果在USR1的信号处理程序执行时收到了USR2,则会跳到USR2的信号处理程序,如果不想让它跳,应屏蔽该信号,后续有讲解!

二、目录结构规划(make编译)

主目录名nginx:

_include目录:专门存放各种头文件;

app目录:放主应用程序.c(main()函数所在的文件)以及一些比较核心的文件;

net目录:专门存放和网络处理相关的1到多个.c文件;

proc目录:专门存放和进程处理有关的1到多个.c文件;

signal目录:专门用于存放和信号处理有关的1到多个.c文件。

linux上用tree可以查看目录结构!

三、编译工具make的使用概述(编译出可执行文件)

vs2017可能可以编译linux下项目,但不确定,所以用传统的 ,经过验证没有问题的方式来编译项目,最终生成可执行文件。

每个.c生成一个.o,多个.c生成多个.o,最终这些.o被链接到一起,生成一个可执行文件,如下图: 

正常来讲,编译文件需要如下方式,但如果项目很大且有很多文件夹时显然不现实。

gcc -o nginx ng1.c
gcc -o nginx ng1.c ng2.c

所以要借助make的命令来编译,最终生成可执行文件,大型项目一般都用make。

make命令的工作原理:去当前目录读取一个叫做makefile的文件(文本文件),根据这个makefile文件里的规则把源代码编译成可执行文件,开发者的任务就是要把这个makefile文件写出来。

makefile文件:文本文件,utf8编码格式,没有扩展名,一般放在根目录下[也会根据需要放在子目录]。

makefile里边定义了怎么去编译整个项目的编译、链接规则,实际上makefile文件就是一个我们编译工程要用到的各种源文件等等的一个依赖关系描述,也有类似autotools自动生成makefile。

 【makefile文件的编写】

第一步:nginx根目录下放三个文件:

1)makefile:编译项目的入口脚本,编译项目从这里开始,起总体控制作用;

2)config.mk:配置脚本,被makefile文件include,单独分离出来是为了应付一些可变的东西,一般变动的东西都往这里;

3)common.mk:最重要最核心的编译脚本,定义makefile的编译规则,依赖规则等,各个子目录中都用到这个脚本来实现对应子目录的.c文件的编译。

 第二步:每个子目录下都有一个makefile文件,每个makefile文件都会包含根目录下的common.mk从而实现自己这个子目录下的.c文件的编译,现在的makefile不支持目录中套子目录。子目录下makefile文件如下:

BIN =     //子目录中只生成.d和.o文件,不要求生成可执行文件,所以为空!
include $(BUILD_ROOT)/common.mk

【注意】app/link_obj临时目录,存放.o目标文件,app/dep:存放.d开头的依赖关系文件,make之后在app目录下自动生成。

四、makefile脚本用法介绍

直接在根目录下make,会在app目录中生成两个中间文件夹,和根目录下的一个可执行文件nginx。

make后若想删除这两个多余的文件夹可以make clean,这其实是执行了根目录下makefile文件中的如下两行:

clean:
#-rf:删除文件夹,强制删除
	rm -rf app/link_obj app/dep nginx
	rm -rf signal/*.gch app/*.gch

make原理大致如下: 

五、makefile脚本具体实现讲解

从common.mk讲起(因为所有子目录的makefile也用到了common.mk),SRCS扫描所有目录下.c文件!

all:$(DEPS) $(OBJS) $(BIN)    //以冒号分界:左侧为目标,右侧为依赖

make流程:在根目录下make,先找到makefile文件,for循环遍历所有文件夹进行make(这里include了config.mk),这时里面的make会include common.mk文件并利用其规则生成依赖,并连接每个.o文件生成bin。

将来增加新目录时:

a)修改根目录下的config.mk来增加该目录,注意app应在最后;

b)在对应的目录下放入makefile文件,内容参考signal目录下的makefile文件即可。

读配置文件、查泄漏、设置标题实战

一、基础设施之配置文件读取

使用配置文件,使服务器程序有了极大的灵活性,作为服务器程序开发者,必须要首先搞定这些问题。

配置文件:文本文件,里边除了注释行之外(以#号开头的行)不要用中文,只在配置文件中使用字母、数字、下划线。

写代码要多顾及别人感受,让别人更容易读懂和理解,该缩进的必须要缩进,该对齐的要对齐,该注释的要注释。

在main中先利用单例类创建一个指向配置文件单例类对象的指针,执行load函数,在load函数里打开nginx.conf并处理这个文本文件中的所有内容,具体操作是用打开后逐行读取,删掉不必要的空格。

CConfig *p_config = CConfig::GetInstance();    //单例类
if(p_config->Load("nginx.conf") == false)    //把配置文件内容载入到内存
{
    printf("配置文件载入失败,退出!\n");
    exit(1);
}

load函数其实就是遍历nginx.conf中的每一行,每一行以等号为中心,构建一个指向如下结构的指针,等号前面的部分放入ItemName中,等号后面的部分放入ItemContent中,再将这个指针push_back入容器m_ConfigItemList结构中。

typedef struct
{
    char ItemName[50];
    char ItemContent[500];
}CConfItem,*LPCConfItem;

二:内存泄漏的检查工具

Valgrind:帮助程序员寻找程序里的bug和改进程序性能的工具集。擅长发现内存的管理问题,里边有若干工具,其中最重要的是Memcheck(内存检查)工具,用于检查内存的泄漏。

1、memcheck的基本功能,能发现如下的问题;

a)使用未初始化的内存

b)使用已经释放了的内存

c)使用超过malloc()分配的内存

d)对堆栈的非法访问

e)申请的内存是否有释放

f)malloc/free,new/delete申请和释放内存的匹配

g)memcpy()内存拷贝函数中源指针和目标指针重叠

2、内存泄漏检查示范

config.mk中要把debug选项设为true,表示编译时是否生成调试信息。很多调试工具,包括Valgrind工具集都会因为这个为true能够输出更多的调试信息,但是商业运行的代码为了效率要把这一项设为false。

【格式】

valgrind --tool=memcheck 一些开关 可执行文件名

--tool=memcheck:使用valgrind工具集中的memcheck工具

--leak-check=full:指的是完全检查内存泄漏(显示的多一点)

--show-reachable=yes:是显示内存泄漏的地点

--trace-children=yes:是否跟入子进程

--log-file=log.txt:讲调试信息输出到log.txt,不输出到屏幕

最终用的命令:valgrind --tool=memcheck --leak-check=full --show-reachable=yes ./nginx

查看内存泄漏的三个地方:

(1)9 allocs, 8 frees 差值是1,就没泄漏,超过1就有泄漏;

(2)中间诸如: by 0x401363: CConfig::Load(char const*) (ngx_c_conf.cxx:77)和我们自己的源代码有关的提示,就要注意;

(3)LEAK SUMMARY:definitely lost: 1,100 bytes in 2 blocks(因为一个结构是550字节)

三、设置可执行程序的标题(名称)

目的:以后主进程和子进程不能叫一个名儿啊

argc:命令行参数的个数;

argv:是个数组,每个数组元素都是指向一个字符串的char *,里边存储的内容是所有命令行参数。

比如:键入./nginx -v -s 5

则argc = 4,argv[0] = ./nginx,argv[1] = -v,argv[2] = -s,argv[3] = 5。

argv内存之后,接着连续的就是环境变量参数信息内存,也就是可执行程序执行时有关的所有环境变量参数信息,换句话说,environ内存和argv内存紧紧的挨着,会遇到如下问题:

【问题】如果改名的名称太长,可能会覆盖参数后的环境变量,就出问题了!

【解决方法】

第一步:重新分配一块内存,用来保存environ中的内容(把环境变量这些搬走);

第二步:修改argv[0]所指向的内存。

搬家主要代码如下: 

for (i = 0; environ[i]; i++) 
{
    size_t size = strlen(environ[i])+1 ; //不要拉下+1,否则内存全乱套了,因为strlen是不包括字符串末尾的\0的
    strcpy(ptmp,environ[i]);      //把原环境变量内容拷贝到新地方【新内存】
    environ[i] = ptmp;            //然后还要让新环境变量指向这段新内存
    ptmp += size;
}

日志打印实战,优化main函数调用顺序

一、基础设施之日志打印实战代码一

ngx_printf.cxx:放和打印格式相关的函数

ngx_log.cxx:放和日志相关的函数

ngx_log_stderr():往屏幕上打印一条错误信息,功能类似于printf,可以支持任意自己想支持的格式化字符,%k,%z等。

二:设置时区

以前blog有!

三:基础设施之日志打印实战代码二

1、日志等级划分

划分日志等级,一共分8级,分级的目的是方便管理,显示,过滤等等;日志级别从高到低,数字最小的级别最高,数字最大的级别最低。

#define NGX_LOG_STDERR            0    //控制台错误【stderr】:最高级别日志,日志的内容不再写入log参数指定的文件,而是会直接将日志输出到标准错误设备比如控制台屏幕
#define NGX_LOG_EMERG             1    //紧急 【emerg】
#define NGX_LOG_ALERT             2    //警戒 【alert】
#define NGX_LOG_CRIT              3    //严重 【crit】
#define NGX_LOG_ERR               4    //错误 【error】:属于常用级别
#define NGX_LOG_WARN              5    //警告 【warn】:属于常用级别
#define NGX_LOG_NOTICE            6    //注意 【notice】
#define NGX_LOG_INFO              7    //信息 【info】
#define NGX_LOG_DEBUG             8    //调试 【debug】:最低级别

2、配置文件中和日志有关的选项

void ngx_log_init():打开/创建日志文件

ngx_log_error_core():写日志文件的核心函数

最终都是调ngx_vslprintf函数拼出一个字符串写到日志文件里。

四:捋顺main函数中代码执行顺序

1、无伤大雅也不需要释放的放最上边,比如取得进程ID;

2、初始化失败,就要直接退出的,配置文件必须最先要,后边初始化啥的都用,所以先把配置读出来,供后续使用;

【特别提醒】main中exit和return效果差不多,exit(0)表示程序正常,exit(1)/exit(-1)表示程序异常退出,exit(2)表示表示系统找不到指定的文件。

3、一些初始化函数,比如日志初始化;

4、一些不好归类的其他类别的代码,比如环境变量搬家;

5、该释放的资源要释放掉,main函数返回前写一个freeresource(),用来写一系列释放动作函数。

信号,子进程实战,文件IO详谈

一、信号功能实战

ngx_init_signals用来注册信号处理程序(在日志初始化后),注意用sigaction不用signal,然后往该nginx进程发信号就不会执行默认的kill等动作,会执行ngx_signal_handler这个信号处理程序中的内容。

具体做法是遍历一个signals[]数组,用系统定义的sigaction结构来暂存信号处理函数,用类似sigemptyset(&sa.sa_mask)这种代码来设置屏蔽信号集合为空,也就是不屏蔽任何其他信号,然后用sigaction函数来绑定对应的信号和信号处理函数。

二、nginx中创建worker子进程

官方nginx ,一个master进程,创建了多个worker子进程;fork的子进程数目一般与CPU数目相同。

【代码流程】

(i)ngx_master_process_cycle()        //创建子进程等一系列动作
(i)    ngx_setproctitle()            //设置进程标题    
(i)    ngx_start_worker_processes()  //创建worker子进程   
(i)        for (i = 0; i < threadnums; i++)   //master进程在走这个循环,来创建若干个子进程
(i)            ngx_spawn_process(i,"worker process");
(i)                pid = fork();     
(i)                   //分叉,从原来的一个master进程分成两个叉(原有的master进程,以及一个新fork()出来的worker进程
(i)                //只有子进程这个分叉才会执行ngx_worker_process_cycle()
(i)                ngx_worker_process_cycle(inum,pprocname);  //子进程分叉
(i)                    ngx_worker_process_init();
(i)                        sigemptyset(&set);  
(i)                        sigprocmask(SIG_SETMASK, &set, NULL); //允许接收所有信号
(i)                        ngx_setproctitle(pprocname);          //重新为子进程设置标题为worker process
(i)                        for ( ;; ) {}. ....                   //子进程开始在这里不断的死循环
(i)    sigemptyset(&set); 
(i)    for ( ;; ) {}.                //父进程[master进程]会一直在这里循环

调试技巧:kill -9 -1344 ,用负号+组id,可以杀死一组进程。

sigsuspend()函数讲解:阻塞在这里等待一个信号,此时进程是挂起的,不占用cpu时间,只有收到信号才会被唤醒(返回),这是一个原子操作。因为这个函数时处理信号的,所以适合管理进程master,不适合worker进程。

a)根据给定的参数设置新的mask并阻塞当前进程【因为是个空集,所以不阻塞任何信号】;

b)此时,一旦收到信号,便恢复原先的信号屏蔽【阻塞了多达10个信号】;

c)调用该信号对应的信号处理函数(已经阻塞了好多信号,所以不会被打断);

d)信号处理函数返回后,sigsuspend返回,使程序流程继续往下走。

三、日志输出重要信息谈

1、换行回车进一步示意

\r:回车符,把打印【输出】信息的为止定位到本行开头

\n:换行符,把输出为止移动到下一行

一般把光标移动到下一行的开头用\r\n;如windows下,每行结尾 \r\n;类Unix,每行结尾就只有\n。

结论:统一用\n!

2、printf()函数不加\n无法及时输出的解释

printf末尾不加\n就无法及时的将信息显示到屏幕。这是因为行缓存(类Unix上才有),需要输出的数据不直接显示到终端,而是首先缓存到某个地方,当遇到行刷新表指或者该缓存已满的情况下,才会把缓存的数据显示到终端设备;

ANSI C中定义\n认为是行刷新标记,所以,printf函数没有带\n是不会自动刷新输出流,直至行缓存被填满才显示到屏幕上。

所以用printf的时候,注意末尾要用\n!

其他两种解决方法:

fflush(stdout); //刷新标准输出缓冲区,把输出缓冲区里的东西打印到标准输出设备上,则printf里的东西会立即输出。
setvbuf(stdout,NULL,_IONBF,0); //这个函数直接将printf缓冲区禁止,printf就直接输出了。

四、write()函数思考

【测试】多个进程同时去写一个文件,比如5个进程同时往日志文件中写,会不会造成日志文件混乱。多个进程同时写一个日志文件,可以看到输出结果并不混乱,是有序的;我们的日志代码应对多进程往日志文件中写时没有问题。为什么?

a)多个进程写一个文件(如果没有父子关系),可能会出现数据覆盖,混乱等情况,也就是说如果是每个进程都open打开后然后往里面写就会出问题

b)ngx_log.fd = open((const char *)plogname,O_WRONLY|O_APPEND|O_CREAT,0644)

O_APPEND这个标记能够保证多个进程操作同一个文件时不会相互覆盖

c)内核wirte()写入时是原子操作;

d)父进程fork()子进程是亲缘关系,会共享文件表项。

是否数据成功被写到磁盘?write成功了不等于真写到磁盘了!

write()调用返回时,内核已经将应用程序缓冲区所提供的数据放到了内核缓冲区,但是无法保证数据已经写出到其预定的目的地【磁盘】。的确,因为write()调用速度极快,可能没有时间完成该项目的工作【实际写磁盘】就返回了,所以这个wirte()调用不等价于数据在内核缓冲区和磁盘之间的数据交换。

【掉电导致write()的数据丢失破解法】

a)直接I/O(效率很低):直接访问物理磁盘,open的时候选择O_DIRECT,表示绕过内核缓冲区。

b)open文件时用O_SYNC选项:

同步选项【把数据直接同步到磁盘】,只针对write函数有效,使每次write()操作等待物理I/O操作的完成;

具体说,就是将写入内核缓冲区的数据立即写入磁盘,将掉电等问题造成的损失减到最小;每次写磁盘数据,务必要大块大块写,一般都512-4k 4k的写。

c)缓存同步:尽量保证缓存数据和写道磁盘上的数据一致(推荐!)

sync(void):将所有修改过的块缓冲区排入写队列;然后返回,并不等待实际写磁盘操作结束,数据是否写入磁盘并没有保证;

fsync(int fd):将fd对应的文件的块缓冲区立即写入磁盘,并等待实际写磁盘操作结束返回;

fdatasync(int fd):类似于fsync,但只影响文件的数据部分。而fsync不一样,fsync除数据外,还会同步更新文件属性。

建议:多次write,每次write建议都4k,然后调用一次fsync()。

五、标准I/O库

fwrite和write有啥区别;

fwrite()是标准I/O库,一般在stdio.h文件中,write()是系统调用,操作系统调用的;

所有系统调用都是原子性的。

守护进程及信号处理实战

一、守护进程功能的实现

【回顾】避免bash关闭时进程也关闭了,两种解决方法:

(1)拦截掉SIGHUP,那么终端窗口关闭,进程就不会跟着关闭;

(2)守护进程,三章七节,一运行就在后台,不会占着终端。

创建守护进程ngx_daemon(),worker()子进程创建之前调用ngx_daemon(),这样创建的子进程也都是守护进程了。

创建守护进程后观察:

(1)1个master进程,4个worker进程,状态S,表示休眠状态,但没有+,说明这几个进程不在前台进程组;

(2)master进程的ppid是1【老祖宗进程init】,其他几个worker进程的父进程都是master;

(3)tt这列都为?,表示他们都脱离了终端,不与具体的终端挂钩了;

(4)进程组PGRP都相同。

【结论】

1)守护进程如果通过键盘执行可执行文件来启动,虽然守护进程与具体终端是脱钩的,但是依旧可以往标准错误上输出内容,这个终端对应的屏幕上可以看到输入的内容;

2)如果这个nginx守护进程不是通过终端启动,可能开机就启动,那么这个nginx守护进程就完全无法往任何屏幕上显示信息了,这个时候,要排错就要靠日志文件了;

二、信号处理函数的进一步完善

避免子进程被杀掉时变成僵尸进程:父进程要处理SIGCHILD信号并在信号处理函数中调用waitpid()来解决僵尸进程的问题。

猜你喜欢

转载自blog.csdn.net/u012836896/article/details/89060270
今日推荐