缘起
在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