Lei Lu

"I've always depended on the kindness of strangers"

今天调试了一天的一个bug,最后一小时内被某强人一语惊醒梦中人。貌似找到了root cause,但依旧有些不放心,可能还有其他的原因。其中的发现先记下来,以后有空再慢慢研究,当然如果有牛人愿意指点一二相当欢迎。

问题是这样的,linux系统里有crond进程,运行时依赖两个问题:一个锁文件,一个pid文件。锁文件实现init脚本调用互斥,pid文件用于实现crond单一进程运行。crond的init脚本的start/stop函数以及相关自函数如下:

. /etc/init.d/functions

start() {
        echo -n $"Starting $prog: "
        if [ -e /var/lock/subsys/crond ]; then // 锁文件,实现start/stop函数互斥
            if [ -e /var/run/crond.pid ] && [ -e /proc/`cat /var/run/crond.pid` ]; then //pid文件,实现crond进程互斥
                echo -n $"cannot start crond: crond is already running.";
                failure $"cannot start crond: crond already running.";
                echo
                return 1
            fi
        fi
        echo -n $CRONDARGS
        daemon crond $CRONDARGS // 启动进程,$CRONDARGS是可配置参数,这里为空
        RETVAL=$?
        echo
        [ $RETVAL -eq 0 ] && touch /var/lock/subsys/crond; // start结束后创建锁文件
        return $RETVAL
}

stop() {
        echo -n $"Stopping $prog: "
        if [ ! -e /var/lock/subsys/crond ]; then
            echo -n $"cannot stop crond: crond is not running."
            failure $"cannot stop crond: crond is not running."
            echo
            return 1;
        fi
        killproc crond //杀掉进程
        RETVAL=$?
        echo
        [ $RETVAL -eq 0 ] && rm -f /var/lock/subsys/crond; // 进程成功结束后删除锁文件
        return $RETVAL
}

. /etc/init.d/functions里用到的子函数:

# __proc_pids {program} [pidfile]
# Set $pid to pids from /var/run* for {program}.  $pid should be declared
# local in the caller.
# Returns LSB exit code for the ‘status’ action.
__pids_var_run() {
        local base=${1##*/}
        local pid_file=${2:-/var/run/$base.pid}

        pid=
        if [ -f "$pid_file" ] ; then
                local line p
                read line < "$pid_file"
                for p in $line ; do
                        [ -z "${p//[0-9]/}" -a -d "/proc/$p" ] && pid="$pid $p"
                done
                if [ -n "$pid" ]; then
                        return 0
                fi
                return 1 # "Program is dead and /var/run pid file exists"
        fi
        return 3 # "Program is not running"
}

# A function to start a program.
daemon() {
        # Test syntax.
        local gotbase= force= nicelevel corelimit
        local pid base= user= nice= bg= pid_file=
        nicelevel=0
        while [ "$1" != "${1##[-+]}" ]; do
          case $1 in
            ”)    echo $"$0: Usage: daemon [+/-nicelevel] {program}"
                   return 1;;

             … usage check // 此处省略500字

            *)     echo $"$0: Usage: daemon [+/-nicelevel] {program}"
                   return 1;;
          esac
        done

        # Save basename.
        [ -z "$gotbase" ] && base=${1##*/}

        # See if it’s already running. Look *only* at the pid file.
        __pids_var_run "$base" "$pid_file"

        [ -n "$pid" -a -z "$force" ] && return

        # make sure it doesn’t core dump anywhere unless requested
        corelimit="ulimit -S -c ${DAEMON_COREFILE_LIMIT:-0}"

        # if they set NICELEVEL in /etc/sysconfig/foo, honor it
        [ -n "${NICELEVEL:-}" ] && nice="nice -n $NICELEVEL"

        # Echo daemon
        [ "${BOOTUP:-}" = "verbose" -a -z "${LSB:-}" ] && echo -n " $base"

        # And start it up.
        if [ -z "$user" ]; then
           $nice /bin/bash -c "$corelimit >/dev/null 2>&1 ; $*" // 真正开始起进程,进程内检测并创建pid文件

        else
           $nice runuser -s /bin/bash – $user -c "$corelimit >/dev/null 2>&1 ; $*"
        fi
        [ "$?" -eq 0 ] && success $"$base startup" || failure $"$base startup"
}
# A function to stop a program.
killproc() {
        local RC killlevel= base pid pid_file= delay

        RC=0; delay=3
        # Test syntax.
        if [ "$#" -eq 0 ]; then
                echo $"Usage: killproc [-p pidfile] [ -d delay] {program} [-signal]"
                return 1
        fi
        if [ "$1" = "-p" ]; then
                pid_file=$2
                shift 2
        fi
        if [ "$1" = "-d" ]; then
                delay=$2
                shift 2
        fi

        # check for second arg to be kill level
        [ -n "${2:-}" ] && killlevel=$2

        # Save basename.
        base=${1##*/}

        # Find pid.
        __pids_var_run "$1" "$pid_file"
        if [ -z "$pid_file" -a -z "$pid" ]; then
                pid="$(__pids_pidof "$1")"
        fi

        # Kill it.
        if [ -n "$pid" ] ; then
                [ "$BOOTUP" = "verbose" -a -z "${LSB:-}" ] && echo -n "$base "
                if [ -z "$killlevel" ] ; then
                       if checkpid $pid 2>&1; then
                           # TERM first, then KILL if not dead // 真正开始kill进程
                           kill -TERM $pid >/dev/null 2>&1
                           usleep 100000
                           if checkpid $pid && sleep 1 &&
                              checkpid $pid && sleep $delay &&
                              checkpid $pid ; then
                                kill -KILL $pid >/dev/null 2>&1
                                usleep 100000
                           fi
                        fi
                        checkpid $pid
                        RC=$?
                        [ "$RC" -eq 0 ] && failure $"$base shutdown" || success $"$base shutdown"
                        RC=$((! $RC))
                # use specified level only
                else
                        if checkpid $pid; then
                                kill $killlevel $pid >/dev/null 2>&1
                                RC=$?
                                [ "$RC" -eq 0 ] && success $"$base $killlevel" || failure $"$base $killlevel"
                        elif [ -n "${LSB:-}" ]; then
                                RC=7 # Program is not running
                        fi
                fi
        else
                if [ -n "${LSB:-}" -a -n "$killlevel" ]; then
                        RC=7 # Program is not running
                else
                        failure $"$base shutdown"
                        RC=0
                fi
        fi

        # Remove pid file if any.
        if [ -z "$killlevel" ]; then
            rm -f "${pid_file:-/var/run/$base.pid}" // 删除进程后,删除pid文件
        fi
        return $RC
}

基于以上实现,开始讨论start/stop调用中可能存在的并发问题。

一、同时start两个crond进程,没有问题

在start()开始,会检查锁文件以防止start()多次调用。但是在调用start()和锁文件真正被创建之间还有一段时间窗口,所以不能确保同时只调用一次start()。但是因为pid文件在crond进程中被创建,并且crond在创建pid前会检查有无以有进程存在。所以就算可以同时开始两个start()函数,crond进程自己也确保了同时只能起一个进程。没有问题

二、同时stop两次

没有任何限制,两个stop()可以同时被调用,最终结果是锁文件和pid文件都被删除,看起来没有任何副作用

三、start/stop同时调用

如果start先被调用,由于锁文件的作用,直到start最后一步,stop才能真正开始。否则在start没有结束生成锁文件之前,stop运行不了直接在第一步退出。反之亦然,如果stop先被调用,直到锁文件被真正删除之前,start都不会开始

看起来锁文件和pid文件的两阶段锁彻底解决了这其中的并发调用问题。但是在实际工作中,碰到这么一个问题:crond进程还在,锁文件和pid文件不见了。嘿嘿,想的出来是什么问题么?

其实这里除了start/stop的并发问题之外,还有一个异步问题,pid文件和锁文件创建的顺序,看起来是顺序的,但实际上存在一个crond daemon的异步操作,所以存在时序不确定性。回头看看start函数,start函数先启动了crond daemon,它fork自己两次生成daemon然后开始做事,包括检查pid文件是否已经生成并且没有的话创建自己的。接下去start会创建锁文件。看上去锁文件永远在pid文件之后被创建,但是在crond第一次fork结束,start就会继续往下走。pid文件和锁文件谁先创建出来,是个竞争关系。如果向我们假设的,pid文件先生成,没有问题。但是如果锁文件先被创建出来了呢?

回头来看这个问题,假如时序是这样:

  1. start启动,因为没有任何锁文件pid文件,成功执行
  2. 执行中,先生成crond进程,fork结束后返回,脚本继续向下走
  3. start生成锁文件并退出
  4. stop启动,因为锁文件已生成,成功执行
  5. 因为没有pid文件也没有参数传入,在killproc里实际上杀不了任何进程
  6. 这时候,crond两次fork结束,正常运行,并真正生成了pid文件
  7. stop调用的killproc继续往下走,删除pid文件
  8. stop删除锁文件

BINGO!在这种情况下,crond正常运行,但是既没有pid文件,也没有锁文件。此时stop操作将永远无法执行,start操作将生成crond之外的另一个拥有锁文件和pid文件的crond。之后不管怎么start/stop,第一次那个没有pid文件的crond将永远无法被维护。

所以,解决方法呢?在start/stop里加锁是无济于事的,需要锁定pid文件和锁文件的创建时序。可以在start里创建锁文件之前轮询pid文件,直到查找到pid文件在继续