这篇是跟mmkv相关的分析,上篇主要是一些基本知识介绍。
五) 文件结构和文件锁
粗略介绍下文件结构,引用网上一张图:
如上图,不同进程打开同一个文件时,拥有不同的文件描述符和file对象,但共享唯一的inode节点,其中f_count为引用计数。在同一个进程中,如果使用dup/close fd时,具体会影响f_count变化。
下图是fork一个进程时影响的文件描述表:
其中文件锁的数据结构如下:
struct flcok
{
short int l_type; //锁定的状态
short int l_whence; //决定 l_start位置
off_t l_start; //锁定区域的开头位置
off_t l_len; //锁定区域大小
pid_t l_pid;
};
int fcntl(int fd, int cmd, struct flock *lock);
其中l_type
有F_WRLCK/F_RDLCK/F_UNLCK,而使用接口fcntl中的cmd有F_SETLK/F_SETLKW等,这里主要列出后面即将使用到的情况。
六) leveldb中怎么防止同一个进程被启动多次
这里拷贝leveldb中的代码来说明:
351 static int LockOrUnlock(int fd, bool lock) {
352 errno = 0;
353 struct flock f;
354 memset(&f, 0, sizeof(f));
355 f.l_type = (lock ? F_WRLCK : F_UNLCK); //加写锁或解锁
356 f.l_whence = SEEK_SET;
357 f.l_start = 0;
358 f.l_len = 0; // Lock/unlock entire file
359 return fcntl(fd, F_SETLK, &f);
360 }
524 virtual Status LockFile(const std::string& fname, FileLock** lock) {
525 *lock = nullptr;
526 Status result;
527 int fd = open(fname.c_str(), O_RDWR | O_CREAT, 0644);
528 if (fd < 0) {
529 result = PosixError(fname, errno);
530 } else if (!locks_.Insert(fname)) {
531 close(fd);
532 result = Status::IOError("lock " + fname, "already held by process");
533 } else if (LockOrUnlock(fd, true) == -1) {
534 result = PosixError("lock " + fname, errno);
535 close(fd);
536 locks_.Remove(fname);
537 } else {
538 PosixFileLock* my_lock = new PosixFileLock;
539 my_lock->fd_ = fd;
540 my_lock->name_ = fname;
541 *lock = my_lock;
542 }
543 return result;
544 }
545
546 virtual Status UnlockFile(FileLock* lock) {
547 PosixFileLock* my_lock = reinterpret_cast<PosixFileLock*>(lock);
548 Status result;
549 if (LockOrUnlock(my_lock->fd_, false) == -1) {
550 result = PosixError("unlock", errno);
551 }
552 locks_.Remove(my_lock->name_);
553 close(my_lock->fd_);
554 delete my_lock;
555 return result;
556 }
LockOrUnlock
中后三个参数是用于加锁整个文件,以排他锁的方式,这样其他进程当以约定的格式对文件加写锁或读锁时,会返回错误。这里需要注意的是,以读写形式打开文件,如果以只读方式打开文件,那么只能加读锁,反之是写锁,代码还是挺好理解的。
七) 改造优化文件锁
如果让我实现一个能递归加解锁且能进行锁升降级的功能我会怎么去实现?
按照既有的约定,同一个进程中,可以对同一文件加锁,后一种会覆盖前一种,但前提是以读写形式打开文件,原因参考上面。
比如在同一个进程中,以读写形式打开文件,那么第一次加读锁时,后面加读锁或者写锁会覆盖前一种锁,加写锁也是如此。
当有两个进程协作时,还是以读写形式打开文件,进程p加读锁,进程q可以加读锁成功,但加写锁就不行;进程p加写锁,进程q不能加读锁或者写锁。
来看看mmkv中的文件锁是如何改造的,基本思路是上面说的:
27 enum LockType {
28 SharedLockType,
29 ExclusiveLockType,
30 };
32 // a recursive POSIX file-lock wrapper
33 // handles lock upgrade & downgrade correctly
34 class FileLock {
35 int m_fd;
36 struct flock m_lockInfo;
37 size_t m_sharedLockCount;
38 size_t m_exclusiveLockCount;
39
40 bool doLock(LockType lockType, int cmd);
41
42 // just forbid it for possibly misuse
43 FileLock(const FileLock &other) = delete;
44
45 FileLock &operator=(const FileLock &other) = delete;
46
47 public:
48 FileLock(int fd);
50 bool lock(LockType lockType);
52 bool try_lock(LockType lockType);
54 bool unlock(LockType lockType);
55 };
以上是文件锁的类声明,其中的两个数据成员用于计数读和写。
25 static short LockType2FlockType(LockType lockType) {
26 switch (lockType) {
27 case SharedLockType:
28 return F_RDLCK;
29 case ExclusiveLockType:
30 return F_WRLCK;
31 }
32 }
33
34 FileLock::FileLock(int fd) : m_fd(fd), m_sharedLockCount(0), m_exclusiveLockCount(0) {
35 m_lockInfo.l_type = F_WRLCK; //默认为写锁
36 m_lockInfo.l_start = 0;
37 m_lockInfo.l_whence = SEEK_SET;
38 m_lockInfo.l_len = 0;
39 m_lockInfo.l_pid = 0;
40 }
42 bool FileLock::doLock(LockType lockType, int cmd) {
43 bool unLockFirstIfNeeded = false;
44
45 if (lockType == SharedLockType) {
46 m_sharedLockCount++;
47 // don't want shared-lock to break any existing locks
48 if (m_sharedLockCount > 1 || m_exclusiveLockCount > 0) {
49 return true;
50 }
51 } else {
52 m_exclusiveLockCount++;
53 // don't want exclusive-lock to break existing exclusive-locks
54 if (m_exclusiveLockCount > 1) {
55 return true;
56 }
57 // prevent deadlock
58 if (m_sharedLockCount > 0) {
59 unLockFirstIfNeeded = true;
60 }
61 }
62
63 m_lockInfo.l_type = LockType2FlockType(lockType);
64 if (unLockFirstIfNeeded) {
65 // try lock
66 auto ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
67 if (ret == 0) {
68 return true;
69 }
70 // lets be gentleman: unlock my shared-lock to prevent deadlock
71 auto type = m_lockInfo.l_type;
72 m_lockInfo.l_type = F_UNLCK;
73 ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
74 if (ret != 0) {
77 }
78 m_lockInfo.l_type = type;
79 }
80
81 auto ret = fcntl(m_fd, cmd, &m_lockInfo);
82 if (ret != 0) {
84 return false;
85 } else {
86 return true;
87 }
88 }
90 bool FileLock::lock(LockType lockType) {
91 return doLock(lockType, F_SETLKW);
92 }
93
94 bool FileLock::try_lock(LockType lockType) {
95 return doLock(lockType, F_SETLK);
96 }
以上是加锁的实现,为了实现递归锁,需要使用计数的形式,这里以mmkv中的使用方式来说明如何正确实现递归和锁的升降级,部分代码如下:
57 class InterProcessLock {
58 FileLock *m_fileLock;
59 LockType m_lockType;
61};
44 class MMKV {
65 FileLock m_fileLock;
66 InterProcessLock m_sharedProcessLock;
67 InterProcessLock m_exclusiveProcessLock;
217};
59 MMKV::MMKV(const std::string &mmapID, int size, MMKVMode mode, string *cryptKey)
65 , m_fileLock(m_metaFile.getFd())
66 , m_sharedProcessLock(&m_fileLock, SharedLockType)
67 , m_exclusiveProcessLock(&m_fileLock, ExclusiveLockType)
99 }
使用方式SCOPEDLOCK(m_exclusiveProcessLock)
,一个mmkv对象持有指向同一文件锁的两种不同锁对象:共享锁和排他锁。
98 bool FileLock::unlock(LockType lockType) {
99 bool unlockToSharedLock = false;
100
101 if (lockType == SharedLockType) {
102 if (m_sharedLockCount == 0) {
103 return false;
104 }
105 m_sharedLockCount--;
106 // don't want shared-lock to break any existing locks
107 if (m_sharedLockCount > 0 || m_exclusiveLockCount > 0) {
108 return true;
109 }
110 } else {
111 if (m_exclusiveLockCount == 0) {
112 return false;
113 }
114 m_exclusiveLockCount--;
115 if (m_exclusiveLockCount > 0) {
116 return true;
117 }
118 // restore shared-lock when all exclusive-locks are done
119 if (m_sharedLockCount > 0) {
120 unlockToSharedLock = true;
121 }
122 }
123
124 m_lockInfo.l_type = static_cast<short>(unlockToSharedLock ? F_RDLCK : F_UNLCK);
125 auto ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
126 if (ret != 0) {
128 return false;
129 } else {
130 return true;
131 }
132 }
这里为了简化讨论分两步。当只有一个进程时,第一次加读锁时,使用方式SCOPEDLOCK(m_sharedProcessLock)
,m_sharedLockCount
为一,加读锁成功;后续加读锁纯粹计数。而后面要加写锁时,使用SCOPEDLOCK(m_exclusiveProcessLock)
,此时m_exclusiveLockCount
为1,判断得出unLockFirstIfNeeded
为真,此时锁升级,先加写锁,如果成功,则返回true;这里并没有把对读次数减一,因为当锁降级时,还需要保留读锁;当加写锁失败时,需要解读锁,防止出现两个进程同时加写锁,导致死锁,这里有一方先退出即先解掉占有的读锁,这样另一方必定会加写锁成功。这个过程是lock的情况。
当解锁时,如果解读锁,m_sharedLockCount
为零表示一种错误并返回;先对读计数减一,如果有读写计数大于零,表示进行了递归加锁,不释放锁处理,否则解锁(不再占有)。当解写锁时,如果m_exclusiveLockCount
为零表示一种错误并返回,否则对m_exclusiveLockCount
减一,如果大于零则表示之前进行的是递归加写锁;否则此时不再成为写锁,那么尝试解锁或者进行锁降级为读锁,取决于m_sharedLockCount
是否大于零,最后根据
m_lockInfo.l_type = static_cast<short>(unlockToSharedLock ? F_RDLCK : F_UNLCK);
判断是加读锁还是完全解锁。这个过程是unlock的过程。
总之,对于单进程,加锁解锁的次数要对应,锁升级时正确处理进程间死锁的情况,锁降级时能不能完全解锁,还是回归到读锁等情况。
然后对于两个进程,区别在于锁升级的情况,其他的都比较好理解。
如果用两个不相关的进程demo测试文件锁的情况,约定文件以读写方式打开,其中一个进程对文件锁进行了加写锁成功,sleep一段时间,另一个进程cmd为F_SETLK
,会出现Resource temporarily unavailable
。当把获取锁的进程kill时,就能成功获取锁;
—————————
今天线上出现了玩家无法登陆游戏的问题,在游戏中的玩家没问题,后来日志报了几个错误,联系问题的时间点,应该是这里引起的。堆栈打印stack overflow,前几帧和后几帧信息打印出来了,中间跳过一堆,行号大概十几万行,跟同事一起分析了下代码,大概是队伍跟随问题导致存在环,那么遍历的时候就死循环了,工作线程再也无法脱离这个消息队列,包括发给这个消息队列的消息,可能之前没有测试到这种情况,而且不是很明显的死循环。后来让相关同事修复了这个bug。虽然这个不算是关键路径,但是因为一个消息队列独占一个工作线程,而且其他的消息处理不了,导致都超时,且占用更多内存,引起雪崩或者如上面的玩家无法进游戏的情况。