代码改变世界

进程互斥与竞态

2011-07-15 09:33  zhenjing  阅读(3941)  评论(0编辑  收藏  举报

缘起

在linux编程中,经常有这样的要求:特定进程(尤其是daemon进程)有且只有一个,即特定资源只能由一进程拥有。问题是:如何保证特定进程间的“互斥”关系(只有一个实例)?当检测到“互斥(锁定)”时,其余进程可直接退出,而无需同步。

互斥与同步

互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。

同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。

(以上摘自百度知道)

Linux提供的同步机制:信号量、文件锁(文件记录锁和文件锁)、互斥量、条件变量。其中后两者需要依赖于共享内存才能用于进程间同步,因此只有文件锁是进程生存期的资源,其他的都属于内核生存期资源。除此之外,信号也可用于进程同步。

网络端口

如果进程需要监听特定的端口(如60000),那么在进程起来之后,可直接尝试连接该特定端口,只要能够连上,即可说明该端口已被使用,进程退出。由于listen/connect均是原子操作,故该判断过程不存在竞态。这种方法极其简单且可靠。

既然端口可用于判断,自然会想利用unix socket来作为替代技术(unix socket远大于65535)。但是由于unix socket将在文件系统上创建一个文件,该文件必须被显式删除,后续的bind方能正常工作,故该方法存在缺陷:没有可靠的办法保证文件必定被删除。(后面分析)

文件锁

另一种很常见的方法是:在特定的路径(路径可为配置参数)下创建一个“众所皆知”的文件,并利用独占锁/写锁保证在进程生存期内有且只有一个进程拥有该文件锁。文件锁属于进程生存期资源,不管进程是否正常终止,进程终止后,文件锁一定被释放。

作为一个加强,可将拥有文件锁的进程PID写入文件,从而在删除锁文件时更“可靠”。问题是:若考虑删除文件,该方案将存在缺陷:删除文件和创建文件是两个系统调用,存在“竞态”。后面将讨论文件删除问题。

信号量和进程锁(共享内存)

信号量和进程锁都属于内核生存期资源。若进程异常终止,信号量和进程锁可能处于“不确定状态”,加上进程无法“得知”是否有其他进程使用相同的信号量或进程锁,导致后续进程不能正常工作。不推荐。

系统调用与竞态

linux系统编程中,经常会出现“竞态(race condition)”,即多进程的资源获取冲突或者访问时序问题。Linux提供的绝大多数系统调用函数保证函数调用过程是原子的(并非所有的系统调用均是原子的,见附录),即单函数调用在返回或终止之前,该函数的操作是原子的,不受其他系统调用影响。但很多系统调用往往需要配合使用,由多个系统调用组成的调用组合,操作系统是无法保证原子性的!这意味着:2个以上系统调用组合在多进程环境下将出现“竞态”。如何避免竞态是linux系统编程的一个大问题。

文件操作的竞态分析

凡涉及多于2个的系统调用,必存在竞态:

示例1:lseek+read

off_t orig;

orig = lseek(fd, 0, SEEK_CUR);    /* Save current offset */

lseek(fd, offset, SEEK_SET);

s = read(fd, buf, len);

lseek(fd, orig, SEEK_SET);        /* Restore original file offset */

示例2:access+create

if(access(file, F_OK) !=0){

       int fd = open((char*)arg, O_RDWR|O_CREAT, 0644);

}

示例3:删除nfs文件系统的文件夹

Cloes(fd);

Remove_Dir(path);

注:fd指向的文件已经被删除,在fd被close之前,该文件将被重命名为.nfs***的临时文件。

示例4:unit socket (TLPI 57-3)

    struct sockaddr_un addr;
    int sfd, cfd;
    ssize_t numRead;
    char buf[BUF_SIZE];
    sfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sfd == -1)
        errExit("socket");
    /* Construct server socket address, bind socket to it,
       and make this a listening socket */
    if (remove(SV_SOCK_PATH) == -1 && errno != ENOENT)
        errExit("remove-%s", SV_SOCK_PATH);
    memset(&addr, 0, sizeof(struct sockaddr_un));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SV_SOCK_PATH, sizeof(addr.sun_path) - 1);
    if (bind(sfd, (struct sockaddr *) &addr, sizeof(struct sockaddr_un)) == -1)
        errExit("bind");
    if (listen(sfd, BACKLOG) == -1)
        errExit("listen");

注:该程序在remove和bind之间存在竞态,即有可能另一程序删除该被刚创建的unix socket文件。对于其他的系统资源,如POSIX信号量,POSIX消息队列,POSIX共享内存,其本质也是文件(通常位于/dev/shm/),且这些文件和普通文件一样可“加锁”!

文件锁示例

文件锁机制是一个可靠的进程间同步机制(信号量等机制存在缺陷)。使用该机制并不要求删除“锁文件”,不当的文件删除反而会引入潜在问题。

“锁文件”删除场景分析:

1)      创建后立马删除(create + unlink)

这种做法将导致其他进程“看不到”锁文件,从而创建另一个新文件。

2)      删除文件时未加锁

文件锁和文件记录锁若使用不当,锁会因其他操作而释放,从而导致删除文件时,删除进程并未锁定该文件。若此场景出现,则意味着锁文件的“创建+删除”并非原子操作,从而出现竞态。

3)      程序异常终止

删除文件这个美好的愿望可能因程序异常终止而无法实现。

4)      “创建+删除”原子操作且正常执行

只有在这样的条件下,方能保证完美删除锁文件。(但谁能保证程序永远正确呢?)

总之,使用锁文件同步进程无需也不应该去删除锁文件。下面的例子是来自TLPI(The Linux Programming Interface) 55-4:

int createPidFile(const char *progName, const char *pidFile, int flags)
{
    int fd;
    char buf[BUF_SIZE];
    fd = open(pidFile, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
    if (fd == -1)
        errExit("Could not open PID file %s", pidFile);
    if (flags & CPF_CLOEXEC) {
        /* Set the close-on-exec file descriptor flag */
        flags = fcntl(fd, F_GETFD);                     /* Fetch flags */
        if (flags == -1)
            errExit("Could not get flags for PID file %s", pidFile);
        flags |= FD_CLOEXEC;                            /* Turn on FD_CLOEXEC */
        if (fcntl(fd, F_SETFD, flags) == -1)            /* Update flags */
            errExit("Could not set flags for PID file %s", pidFile);
    }
    if (lockRegion(fd, F_WRLCK, SEEK_SET, 0, 0) == -1) {
        if (errno  == EAGAIN || errno == EACCES)
            fatal("PID file '%s' is locked; probably "
                     "'%s' is already running", pidFile, progName);
        else
            errExit("Unable to lock PID file '%s'", pidFile);
    }
    if (ftruncate(fd, 0) == -1)
        errExit("Could not truncate PID file '%s'", pidFile);
    snprintf(buf, BUF_SIZE, "%ld\n", (long) getpid());
    if (write(fd, buf, strlen(buf)) != strlen(buf))
        fatal("Writing to PID file '%s'", pidFile);
    return fd;
}

几点说明:

1)      O_CREAT的open方式将保证锁文件被创建或正确打开,即使多个进程同时执行也没有问题。Open是原子的,有且只有一个文件被创建。

2)      lockRegion采用的是文件记录锁,也可以换成文件锁(flock)。只有fcntl才能用于NFS。

3)      将进程PID写入锁文件有助于其他程序判断该锁文件是否有效(和文件是否锁定无关),对安全删除锁文件有帮助,比如垃圾清理进程。

另一种实现:

    int fd = open(lockfile.c_str(), O_RDWR|O_CREAT|O_EXCL, 0644);
    if(fd < 0){
        if(errno == EEXIST){
            fd = open(lockfile.c_str(), O_RDWR);
        }
    }
    if(fd < 0){
        char buf[512] = {0};
        strerror_r(errno, buf, 512);
        exit(-1);
    }

    if(writelock(fd) < 0){  // only one process will get the lock.
        char buf[512] = {0};
        strerror_r(errno, buf, 512);
        exit(-1);
    }

几点说明:

1)      O_CREAT|O_EXCL将保证有且只有一个进程能够创建锁文件。

2)      通过文件锁保证有且只有一个进程获得文件锁。

3)      第一种实现更为简单且优雅。

附录

不保证原子性的系统调用:

1) write() -- write N bytes to PIPE,if N > PIPE_BUF, then write is not atomic!

2) flock() -- lock convert is not guarantee to be atomic. fcntl() guarantee all operators are atomic.

参考文献

The Linux Programming Interface

相关资源

文件锁与NFS文件锁

RAII、栈展开和程序终止

RAII and system resource cleanup