从CGI脚本启动后台进程/守护进程

18 投票
10 回答
13346 浏览
提问于 2025-04-16 17:46

我正在尝试从一个CGI脚本中启动一个后台进程。简单来说,当用户提交一个表单时,CGI脚本会告诉用户他们的请求正在处理中,而后台脚本则负责实际的处理工作(因为这个处理通常需要很长时间)。我遇到的问题是,Apache不会在子脚本结束之前将父CGI脚本的输出发送到浏览器。

我的一个同事告诉我,我想做的事情是不可能的,因为没有办法让Apache不等待CGI脚本的整个进程树结束。不过,我在网上看到很多关于“二次分叉”技巧的讨论,听说这个技巧可以解决这个问题。这个技巧在这个Stack Overflow的回答中有简要描述,我在其他地方也见过类似的代码。

这是我写的一个短脚本,用来测试Python中的二次分叉技巧:

import os
import sys

if os.fork():
    print 'Content-type: text/html\n\n Done'
    sys.exit(0)

if os.fork():
    os.setsid()
    sys.exit(0)

# Second child
os.chdir("/")
sys.stdout.close()
sys.stderr.close()
sys.stdin.close()

f = open('/tmp/lol.txt', 'w')

while 1:
     f.write('test\n')

如果我从命令行运行这个脚本,它的表现正如我所预期的那样:原始脚本和第一个子进程结束,而第二个子进程会继续运行,直到我手动终止它。但是如果我通过CGI访问它,页面不会加载,直到我杀掉第二个子进程,或者因为CGI超时而被Apache杀掉。我还尝试用os._exit(0)替换第二个sys.exit(0),但没有任何区别。

我哪里做错了呢?

10 个回答

6

我觉得这里有两个问题:setsid 放错地方了,还有在某个临时子进程中做了缓冲输入输出操作。

if os.fork():
  print "success"
  sys.exit(0)

if os.fork():
  os.setsid()
  sys.exit()

你有一个原始进程(祖父进程,打印“成功”),一个中间父进程,还有一个孙子进程(“lol.txt”)。

os.setsid() 的调用是在中间父进程中进行的,而且是在孙子进程被创建之后。中间父进程在孙子进程创建后就无法再影响它的会话了。试试这样:

print "success"
sys.stdout.flush()
if os.fork():
    sys.exit(0)
os.setsid()
if os.fork():
    sys.exit(0)

这样做会在创建孙子进程之前就创建一个新会话。然后中间父进程就结束了,这样会话就没有了进程组的领导者,确保任何打开终端的调用都会失败,这样就不会在终端输入或输出上出现阻塞,也不会给子进程发送意外的信号。

注意,我还把success的打印移到了祖父进程那里;因为在调用fork(2)之后,哪个子进程先运行是没有保证的,你有可能在中间父进程还没来得及把success写到远程客户端时,子进程就已经被创建,并可能尝试向标准输出或标准错误输出写内容。

在这种情况下,流很快就关闭了,但在多个进程之间混合使用标准输入输出流肯定会带来麻烦:如果可以的话,尽量把所有操作都放在一个进程里。

编辑 我发现了一个奇怪的行为,我无法解释:

#!/usr/bin/python

import os
import sys
import time

print "Content-type: text/plain\r\n\r\npid: " + str(os.getpid()) + "\nppid: " + str(os.getppid())
sys.stdout.flush()

if os.fork():
    print "\nfirst fork pid: " + str(os.getpid()) + "\nppid: " + str(os.getppid())
    sys.exit(0)

os.setsid()

print "\nafter setsid pid: " + str(os.getpid()) + "\nppid: " + str(os.getppid())

sys.stdout.flush()

if os.fork():
    print "\nsecond fork pid: " + str(os.getpid()) + "\nppid: " + str(os.getppid())
    sys.exit(0)

#os.sleep(1) # comment me out, uncomment me, notice following line appear and dissapear
print "\nafter second fork pid: " + str(os.getpid()) + "\nppid: " + str(os.getppid())

最后一行after second fork pid 只有在注释掉os.sleep(1)调用时才会出现。当这个调用保留时,最后一行在浏览器中从未出现。(但其他所有内容都会打印到浏览器中。)

7

我不建议你这样解决这个问题。如果你需要异步执行某个任务,为什么不使用像 beanstalkd 这样的工作队列呢?这样就不用从请求中分离出任务了。对于 Python 也有可以使用的 beanstalkd 客户端库。

14

别用分叉 - 单独运行批处理

这种双重分叉的方法有点像是个小技巧,我觉得这说明不应该这么做 :)。特别是对于CGI来说。一般来说,如果你发现某件事情太难实现,可能是你走错了方向。

幸运的是,你提供了背景信息,说明你需要的是一个CGI调用来启动一些独立的处理,然后再返回给调用者。没问题,实际上有一些Unix命令可以做到这一点——可以安排命令在特定时间运行(at),或者在CPU空闲时运行(batch)。所以,试试这样做:

import os

os.system("batch <<< '/home/some_user/do_the_due.py'")
# or if you don't want to wait for system idle, 
#   os.system("at now <<< '/home/some_user/do_the_due.py'")

print 'Content-type: text/html\n'
print 'Done!'

这样就可以了。记住,如果有任何输出到标准输出或标准错误,这些内容会被发邮件给用户(这对调试很有帮助,但其他情况下,脚本最好保持安静)。

附言:我刚想起来,Windows也有at的版本,所以稍微修改一下调用方式,你也可以在Windows的Apache上使用它(而不是那种在Windows上不管用的分叉技巧)。

再附言:确保运行CGI的进程没有被排除在/etc/at.deny之外,这样才能安排批处理任务。

撰写回答