很多时候,我们需要做一些诸如爬虫之类的服务,比较常见也比较简单的,我们是采用一个控制台程序,然后再把程序丢到服务器上执行。
这种方式的优缺点都非常明显,优点就是简单直接,暴力美学的典范,而缺点就是它跟tty(终端)进行了绑定,且不说控制台会在手贱的时候不小心关掉,更麻烦的是他的进程会在用户登出的时候也跟着被销毁。
Windows中的NT服务,*inux中的Daemon都是为了应对这种情况而各自提出的解决方案,NT服务使用C#实现非常容易,这里就不提。本篇我们要介绍的是Daemon,Daemon一般使用C/C++进行开发,不过包括我在内的很多C#er其实并不怎么懂C/C++,也很难自己用C/C++来开发一个Daemon。庆幸的是通过一系列的摸索以及踩坑,发现其实用C#也是可以做相同的效果,于是乎我赶紧把它记录了下来。
下面我将为各位读者们介绍各自实现的方式以及原理:
一、nohup命令
使用nohup命令是一种比较常见,用得也比较多的一种直接让程序转化成Daemon的方式,其具体用法如下:
nohup 运行程序的命令 &
可以看到,使用了nohup命令后,终端将被阻塞中解放出来,通过ps命令也可以看到,控制台并没有退出,而是变成了一个后台程序。但这个后台程序跟直接用&最大的区别就是:使用了nohup之后,即便用户登出了终端,控制台程序还是照样运行。
这是因为,当终端的回话被结束时,Linux会发出一个HUP信号并试图“清除”所有跟此终端相关联的进程,而使用了nohup命令之后,该程序将忽略接收到的hup信号,从而能够避免了被终结的命运。
二、subshell
第二种让控制台程序变成Daemon的方式是使用subshell,它的具体用法就是用一个括号把要执行的shell命令包起来
(要运行的程序命令b &)
同样也是把控制台转化成一个后台进程(&引发的),同样也是终端的回话结束后程序依然保持在后台中常驻。但subshell和nohup的原理是不同的,这里我们先借助一下pstree这条命令看看当前进程之间的关系。如果系统报找不到pstree命令则需要先运行“yum install psmisc”来安装(腊鸡RHEL/CentOS 7)。
一下子上了三张大图,里面也已经清晰的标注了subshell和其余两种的区别,是的很明显的subshell所产生的进程并不是挂在bash之下,而是直接挂到systemd(即pid=1)中,由于该进程(控制台)的父进程已经不是bash(其实就是终端),自然也不会受到bash的影响了。
三、仿C/C++Daemon写法
本文的开篇也有提过,Daemon一般是采用C/C++来编写,而.NET又拥有强大的P/Invoke机制,这使得使用C#编写C/C++程序成为了可能(日常),这点在宇内流云的《.NET跨平台实践:用C#开发Linux守护进程》也有教,这里我将介绍如何通过P/Invoke机制使用DllImport大法来实现。
要实现一个Daemon,其主要的要点有以下几点:
1、在后台运行:将Daemon放入后台运行,通过fork&exit使父进程终止(下面代码中的第一组fork代码段),让子进程成为孤儿进程被init领养。
2、重新设置进程组和会话组:这里简单介绍下,Linux中的进程包含在进程组里,而一个进程组的组号(GID)就是该进程组组长的进程号(PID)。新创建的进程的进程组通常是从父进程中继承下来,即与终端的组号相同,我们的目标是将我们的Daemon从终端中脱离。通过调用setsid可以产生新的会话和程序组并让该程序成为新的程序组的组长,由于会话对控制终端具有独占性,老会话产生的信号将无法传递入新的进程组,因此程序也跟老终端进行了隔离。
3、禁止进程重新打开控制终端:虽然当前的进程已经脱离了老终端的控制,但由于刚才调用setsid做脱离的同时让子进程成为了新进程组组长,进程组组长进程也被成为控制进程,它是新会话的控制终端,控制终端可以产生输入或信号给该进程组下的所有进程,这里仍然需要通过fork&exit产生结束子进程并产生第二子进程,不过这里不在需要使用setsid。
4、关闭已经打开的文件描述符:简单说,就是由于Daemon已经和外部脱离了关系,因此也不再需要stdin、stdout了。
5、改变当前的工作目录:如题意,这里我们随意改改,譬如“/tmp”
6、重设umask:由于新进程的umask是从父进程中继承下来的,我们需要重设一下,通过调用umask(0)来进行重置,权限变为:xwrxwrxwr。
7、处理信号:这里不是必须的,不介绍。
上面的解析看不懂没关系,会改下面的Demo代码即可:
using System; using System.IO; using System.Runtime.InteropServices; using System.Threading; namespace Jhonge { public class Program { [DllImport("libc", SetLastError = true)] private static extern int fork(); [DllImport("libc", SetLastError = true)] private static extern int setsid(); [DllImport("libc", SetLastError = true)] private static extern int umask(int mask); [DllImport("libc", SetLastError = true)] private static extern int open([MarshalAs(UnmanagedType.LPStr)]string pathname, int flags); [DllImport("libc", SetLastError = true)] private static extern int close(int fd); [DllImport("libc", SetLastError = true)] private static extern int exit(int code); [DllImport("libc", SetLastError = true)] private static extern int chdir([MarshalAs(UnmanagedType.LPStr)]string pathname); public static void Main(string[] args) { var pid = fork(); if (pid > 0) exit(0); //结束父进程,子进程继续 else if (pid < 0) exit(1); //如果fork失败,退出 setsid(); //让第一子进程成为新的会话组长和进程组长 pid = fork(); if (pid > 0) exit(0);//结束第一子进程,第二子进程继续,禁止重新申请打开终端 else if (pid < 0) exit(1);//失败则退出 //关闭打开的文件描述符 int max = open("/dev/null", 0); for (var i = 0; i <= max; i++) close(i); chdir("/tmp"); //改变工作目录到/tmp umask(0);//重设文件创建掩模 //我的具体方法 MyMethod(); } private static void MyMethod() { while (true) { //###################################### //这里是具体要执行的语句,我这里就凑合凑合 var path = "/jhonge.txt"; if (false == File.Exists(path)) File.Create(path); File.AppendAllText(path, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\r\n"); //###################################### Thread.Sleep(1000); //防止函数退出而引起的GC误回收 } } } }
然后我们去运行它:
可以看到效果也像nohup和subshell一样 ,我们再调用一下pstree看看进程树。
同样的进程也是脱离了出来并且名字还改了下。
当然,其实用在Linux中用C#写一个Daemon程序并不止以上的几种方法,还有譬如使用supervisor(腊鸡RHEL/CentOS7中的supervisor有点问题)、xinetd等方法去实现,这里我暂时还不懂,就不作演示了。
还有几点要注意的:
1、Daemon不能再使用诸如Console.ReadLine、Console.WriteLine之流的输入输出语句,否则你的程序将会出现一些不可控的错误。
2、既然用不了Console.WriteXXX了,如何让程序Hold住,这里可以使用While(true)Thread.Sleep(1000)的方式。
3、关于2那里的,建议把这个语句放到你的函数体(MyMethod)里,防止由于MyMethod已经执行完毕而被GC销毁了对象。
4、怎么关闭程序?nohup和subshell可以使用pkill dotnet/mono或者kill -9 pid的方式来关闭程序,而仿C/C++的那种只能使用Kill -9 pid(譬如 kill -9 11601)来关闭程序,至于为啥,本文中其实已经隐含了原因,各位读者不妨想想。