Skynet源码之:守护进程(4)

JavenLaw

守护进程,顾名思义就是在操作系统后台运行Skynet

守护进程相关知识:搞懂进程组、会话、控制终端关系,才能明白守护进程如何创建 - 知乎 (zhihu.com)

Skynet中的守护进程

在文件skynet_start.c文件,第266行中,有守护进程的代码:

if (config->daemon) {
	if (daemon_init(config->daemon)) {
		exit(1);
	}
}
// daemon()守护进程是否开启是可以在config->daemon中配置的
// config->daemon 其实就是一个文件路径 + 文件名,例如:./path/skynet.pid

让我们看看 daemon_init(config->daemon) 做了什么操作:

int
daemon_init(const char *pidfile) {
	// 检查skynet的pid
    // 具体实现在 代码段1
    int pid = check_pid(pidfile);
	
    // 如果返回的不是0,标识这个文件已经存在,则代表着skynet进程已经启动了
	if (pid) { 
		fprintf(stderr, "Skynet is already running, pid = %d.\n", pid);
		return 1; // skynet进程已启动,直接退出
	}
	
	#ifdef __APPLE__ // 检测代码是否在苹果(Apple)操作系统上运行
		fprintf(stderr, "'daemon' is deprecated: first deprecated in OS X 10.5 , use launchd instead.\n");
	#else
    	// 启动守护进程
        if (daemon(1,1)) {
            fprintf(stderr, "Can't daemonize.\n");
            return 1;
        }
	#endif

    // 读取skynet进程的pid,把pid写入文件中
    // 具体实现在 代码段2
	pid = write_pid(pidfile);
	if (pid == 0) {
		return 1;
	}
	
    // 重定向标准输入,标准输出,标准错误输出
    // 即把上面的3项输出重定向到"/dev/null"
    // 全部输出信息都不打印
	if (redirect_fds()) {
		return 1;
	}
	
	return 0;
}

关于daemon()函数

// daemon()用于创建一个守护进程
// daemon(1, 1)之后,新开了一个子进程,这个子进程就是守护进程
// 函数原型如下:

int daemon(int nochdir, int noclose)
// nochdir用于指定是否改变进程的当前工作目录
// 如果nochdir为非零值,守护进程将不会改变当前工作目录,否则它将切换到根目录
// 
// noclose用于指定是否关闭标准输入、输出和错误输出
// 如果noclose为非零值,守护进程将保持标准输入、输出和错误输出打开,否则它们将被关闭

代码段1:看看check_pid()函数的实现

static int
check_pid(const char *pidfile) {
	int pid = 0;
	FILE *f = fopen(pidfile, "r"); // 打开 config->daemon 配置的文件
	if (f == NULL) // 第一次启动进程,文件肯定是不存在的,直接返回0
		return 0;
    
    // 第二次启动进程,文件肯定存在,但存储的数据是上次进程的pid
    
    // fscanf()是 C 语言标准库中的一个函数,用于从文件中按照指定的格式读取数据
    // 可以通过 fscanf()!=1 来判断是否读取成功,如果返回值不等于1,表示读取失败
	int n = fscanf(f, "%d", &pid); // 读取文件中的值,存储在pid中
	fclose(f);

    // getpid() 函数可以返回调用它的进程的进程ID
   
    // n !=1表示:文件存在,但是读取数据失败,例如:把skynet.pid的数据清空了,留下空文件
    // pid == 0表示:根本没有读取到之前的数据
    // pid == getpid()表示:本次进程pid跟上次进程pid一样,说明没变动
	if (n !=1 || pid == 0 || pid == getpid()) {
		return 0;
	}
    
    // kill(pid, 0) 是一个系统调用函数,用于向指定的进程发送信号
    // 在这个特定的用法中,我们将信号参数设置为 0,它的作用是检查进程是否存在
	// kill(pid, 0) 的返回值有以下几种情况:
	// 		如果成功发送信号(进程存在),返回值为 0
	//		如果目标进程不存在或者当前进程没有权限发送信号,返回值为 -1,并且设置 errno 为适当的错误代码
    // 
	// 当我们使用 kill(pid, 0) 时,我们可以通过检查返回值和 errno 来判断目标进程是否存在
	// errno 是一个全局的错误代码变量,它会在系统调用或库函数发生错误时被设置
    // ESRCH 是一个表示进程不存在的错误代码
	// kill(pid, 0) && errno == ESRCH 的判断可以用来检查进程是否存在
    // 如果判断为真,表示目标进程不存在
    
    // 尝试把上次进程杀掉
    // errno == ESRCH表示:上次进程不存在
	if (kill(pid, 0) && errno == ESRCH)
		return 0;
	
	return pid;
}

代码段2:看看write_pid()函数的实现

write_pid(const char *pidfile) {
	FILE *f;
	int pid = 0;
	int fd = open(pidfile, O_RDWR|O_CREAT, 0644);
	if (fd == -1) {
		fprintf(stderr, "Can't create pidfile [%s].\n", pidfile);
		return 0;
	}
	f = fdopen(fd, "w+");
	if (f == NULL) {
		fprintf(stderr, "Can't open pidfile [%s].\n", pidfile);
		return 0;
	}

	if (flock(fd, LOCK_EX|LOCK_NB) == -1) {
		int n = fscanf(f, "%d", &pid);
		fclose(f);
		if (n != 1) {
			fprintf(stderr, "Can't lock and read pidfile.\n");
		} else {
			fprintf(stderr, "Can't lock pidfile, lock is held by pid %d.\n", pid);
		}
		return 0;
	}
	
	pid = getpid();
	if (!fprintf(f,"%d\n", pid)) {
		fprintf(stderr, "Can't write pid.\n");
		close(fd);
		return 0;
	}
	fflush(f);
	
	return pid;
}

关于输出重定向

static int
redirect_fds() {
    // open("/dev/null", O_RDWR) 的作用是打开一个特殊的设备文件 "/dev/null",并以读写模式打开
    // "/dev/null" 是一个特殊的设备文件,它可以被视为一个黑洞
    // 任何写入该文件的数据都会被丢弃,而任何从该文件读取的操作将立即返回文件结束符
    // 因此将文件描述符指向 "/dev/null" 可以用于丢弃不需要的输出或将输出重定向到空
    // 
    // 在创建守护进程时,常常需要关闭标准输入、标准输出和标准错误输出,以避免守护进程与终端相关联
    // 一种常见的做法是将这些标准文件描述符重定向到 "/dev/null",这样所有输出都会被丢弃
    
	int nfd = open("/dev/null", O_RDWR);
	if (nfd == -1) {
		perror("Unable to open /dev/null: ");
		return -1;
	}
    
    // dup2(nfd, 0) 是一个系统调用函数,它将文件描述符 nfd 复制到标准输入文件描述符(文件描述符为 0)
    // 通过调用 dup2(nfd, 0),我们可以将 nfd 复制到标准输入文件描述符,从而实现将输入重定向到指定的文件描述符
    // 这意味着从标准输入读取数据时,实际上是从 nfd 所指向的文件或设备中读取数据
    // 例如:
    // 		可以通过打开一个文件,并将其文件描述符传递给 dup2() 函数,从而将标准输入重定向到该文件
    // 		这样,守护进程就可以从该文件中读取输入数据
    // 
    // 而下面 dup2(nfd, 0),dup2(nfd, 1),dup2(nfd, 2)的调用
    // 都是为了把标准输入(stdin==0),标准输出(stdout==1),标准错误输出(stderr==2)重定向到 nfd
    // 而 nfd 又是个 “黑洞”,即不会输出任何信息
    
	if (dup2(nfd, 0) < 0) {
		perror("Unable to dup2 stdin(0): ");
		return -1;
	}
	if (dup2(nfd, 1) < 0) {
		perror("Unable to dup2 stdout(1): ");
		return -1;
	}
	if (dup2(nfd, 2) < 0) {
		perror("Unable to dup2 stderr(2): ");
		return -1;
	}
    
    // 上面这部分的代码非常明显:把所有的输出都放入到/dev/null
    // 此后所有的输出都将会看不到,例如:
    // 		fprintf(stdout, "something in here \n"); // 所有stdout、stdin、stderr都失效
    //
    // 因此在《Skynet源码之:service_logger》中我们可以看到,日志的输出如下
    // 		指定日志文件:inst->handle = fopen(parm,"a");
    // 		把输出写入文件:fprintf(inst->handle, "something in here \n");
    // 		即把输出标记为我们指定的日志文件:inst->handle
    // 
    // 而 service_logger 服务也是根据 parm,也就是 config->logger 来判定是否把日志写入文件

	close(nfd);
	
	return 0;
}

总结一下:

在配置config中填写了daemon字段后,skynet在启动的时候就会开启守护进程

不仅开启了守护进程,还会把标准输入,标准输出,标准错误输出都重定向到"/dev/null"

然后把进程的pid写入daemon配置的文件目录和文件名

最后Skynet的终端关闭(会给Skynet发送一个SIGHUP信号,详细看《Skynet源码之:信号》)

这里特别注意:

此时所有的标准输入,标准输出,标准错误输出都重定向到"/dev/null"

那么这个时候是终端屏幕是看不到任何信息的了

而Skynet中的logger服务,会在service_logger.c文件中,第78行,logger_init()中

打开一个配置的日志文件,随后根据 fprintf(inst->handle, “something in here \n”)

把标准输入,标准输出,标准错误输出重定向到指定的日志文件

在config里面配置daemon和在shell里面加 & 让进程后台运行是有区别的:

关于skynet后台运行与lua print · cloudwu/skynet · Discussion #1758 (github.com)

Skynet 的 daemon 参数不仅仅是把进程放在后台,同时还会改为 log 直接写文件