Wound/Wait死锁安全的互斥锁设计
请先阅读通用互斥锁子系统,因为它也适用于等待/伤害互斥锁。
WW-互斥锁的动机
GPU执行的操作通常涉及许多缓冲区。这些缓冲区可以在不同的上下文/进程之间共享,在不同的内存域中存在(例如VRAM与系统内存),等等。而且通过PRIME/dmabuf,它们甚至可以在设备之间共享。因此,驱动程序需要在一些情况下等待缓冲区准备就绪。如果你考虑在等待缓冲区互斥锁以使其可用方面,这会带来问题,因为无法保证缓冲区在所有上下文的execbuf/batch中以相同顺序出现。这直接受用户空间控制,并且是应用程序进行的GL调用序列的结果。这可能导致死锁的潜在可能性。当考虑到内核可能需要在GPU操作缓冲区之前将缓冲区迁移到VRAM时,问题变得更加复杂,这可能又需要驱逐一些其他缓冲区(而你不希望驱逐已排队到GPU的其他缓冲区),但为了简化问题的理解,你可以忽略这一点。
TTM图形子系统为解决这个问题提出的算法非常简单。对于需要被锁定的每组缓冲区(execbuf),调用者将被分配一个唯一的预留ID/票据,来自全局计数器。在锁定与execbuf相关的所有缓冲区时发生死锁时,具有最低预留票据(即最老的任务)的任务获胜,具有较高预留ID(即较年轻的任务)的任务解锁它已经锁定的所有缓冲区,然后再次尝试。
在关系数据库管理系统(RDBMS)文献中,预留票据与事务相关联,死锁处理方法称为等待-死亡(Wait-Die)。名称基于锁定线程在遇到已锁定的互斥锁时的操作。如果持有锁的事务较年轻,则锁定事务等待。如果持有锁的事务较老,则锁定事务后退并死亡。因此称为等待-死亡。还有另一种算法称为伤害-等待(Wound-Wait):如果持有锁的事务较年轻,则锁定事务伤害持有锁的事务,要求其死亡。如果持有锁的事务较老,则等待另一个事务。因此称为伤害-等待。这两种算法都是公平的,因为事务最终将成功。然而,通常说伤害-等待算法与等待-死亡相比会生成更少的后退,但另一方面,当从后退中恢复时,与等待-死亡相比,伤害-等待需要更多的工作。伤害-等待也是一种抢占算法,事务被其他事务伤害,这需要一种可靠的方式来捕获受伤的状态并抢占正在运行的事务。请注意,这与进程抢占不同。当伤害-等待事务死亡(返回-EDEADLK)时,被视为被抢占。
概念
与普通互斥锁相比,在w/w互斥锁的锁接口中出现了两个额外的概念/对象:
获取上下文:为了确保最终的前进,重要的是尝试获取锁的任务不会抓取新的预留ID,而是保留在开始锁获取时获取的那个。这个票据存储在获取上下文中。此外,获取上下文还跟踪调试状态,以捕获w/w互斥锁接口的滥用。获取上下文代表一个事务。
w/w类:与普通互斥锁相比,对于w/w互斥锁,锁类需要是显式的,因为需要初始化获取上下文。锁类还指定要使用的算法,伤害-等待或等待-死亡。
此外,有三种不同类别的w/w锁获取函数:
- 使用上下文进行正常锁获取,使用ww_mutex_lock。
- 在竞争的锁上进行慢路径锁获取,由刚刚在放弃所有已经获取的锁后杀死其事务的任务使用。这些函数带有_slow后缀。
- 从简单的语义角度来看,_slow函数并不严格要求,因为简单地在竞争的锁上调用正常的ww_mutex_lock函数(在放弃所有其他已经获取的锁后)将正确工作。毕竟,如果还没有获取其他ww互斥锁,就没有死锁的可能性,因此ww_mutex_lock调用将阻塞并且不会过早返回-EDEADLK。_slow函数的优势在于接口的安全性:
- ww_mutex_lock具有__must_check int返回类型,而ww_mutex_lock_slow具有void返回类型。请注意,由于ww互斥锁代码需要循环/重试,因此__must_check不会导致虚假警告,即使第一次锁操作永远不会失败。
- 当启用完整调试时,ww_mutex_lock_slow检查所有已获取的ww互斥锁是否已释放(防止死锁),并确保我们在竞争的锁上阻塞(防止通过-EDEADLK慢路径旋转直到竞争的锁可以被获取)。
- 仅获取单个w/w互斥锁的函数,这将产生与普通互斥锁完全相同的语义。这是通过使用空上下文调用ww_mutex_lock来实现的。
再次强调,这并不是严格要求的。但通常你只想获取一个单独的锁,在这种情况下设置获取上下文是没有意义的(因此最好避免获取死锁避免票据)。
当然,还提供了处理由信号引起的唤醒的常规变体。
使用
通过使用DEFINE_WW_CLASS()(Wound-Wait)或DEFINE_WD_CLASS()(Wait-Die)来选择算法(Wait-Die vs Wound-Wait)。作为一个粗略的经验法则,如果你预计同时竞争的事务数量通常较少,并且希望减少回滚的次数,那么使用Wound-Wait。
在同一w/w类中获取锁的三种不同方法。方法#1和#2的常见定义如下:
static DEFINE_WW_CLASS(ww_class); struct obj { struct ww_mutex lock; /* obj data */ }; struct obj_entry { struct list_head head; struct obj *obj; };
方法1,使用execbuf->buffers中的列表,不允许重新排序。如果已经在某个地方跟踪了所需对象的列表,这将非常有用。此外,锁辅助程序可以将-EALREADY返回代码传递回调用者,作为对象在列表中出现两次的信号。如果列表是由用户空间输入构建的,并且ABI要求用户空间不得有重复条目(例如用于gpu命令缓冲区提交ioctl):
int lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx) { struct obj *res_obj = NULL; struct obj_entry *contended_entry = NULL; struct obj_entry *entry; ww_acquire_init(ctx, &ww_class); retry: list_for_each_entry (entry, list, head) { if (entry->obj == res_obj) { res_obj = NULL; continue; } ret = ww_mutex_lock(&entry->obj->lock, ctx); if (ret < 0) { contended_entry = entry; goto err; } } ww_acquire_done(ctx); return 0; err: list_for_each_entry_continue_reverse (entry, list, head) ww_mutex_unlock(&entry->obj->lock); if (res_obj) ww_mutex_unlock(&res_obj->lock); if (ret == -EDEADLK) { /* we lost out in a seqno race, lock and retry.. */ ww_mutex_lock_slow(&contended_entry->obj->lock, ctx); res_obj = contended_entry->obj; goto retry; } ww_acquire_fini(ctx); return ret; }
方法2,使用execbuf->buffers中可以重新排序的列表。与方法1相同,使用-EALREADY进行重复条目检测的语义。但是列表重新排序允许更具惯用性的代码:
int lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx) { struct obj_entry *entry, *entry2; ww_acquire_init(ctx, &ww_class); list_for_each_entry (entry, list, head) { ret = ww_mutex_lock(&entry->obj->lock, ctx); if (ret < 0) { entry2 = entry; list_for_each_entry_continue_reverse (entry2, list, head) ww_mutex_unlock(&entry2->obj->lock); if (ret != -EDEADLK) { ww_acquire_fini(ctx); return ret; } /* we lost out in a seqno race, lock and retry.. */ ww_mutex_lock_slow(&entry->obj->lock, ctx); /* * Move buf to head of the list, this will point * buf->next to the first unlocked entry, * restarting the for loop. */ list_del(&entry->head); list_add(&entry->head, list); } } ww_acquire_done(ctx); return 0; }
对于方法#1和#2,解锁方式相同:
void unlock_objs(struct list_head *list, struct ww_acquire_ctx *ctx) { struct obj_entry *entry; list_for_each_entry (entry, list, head) ww_mutex_unlock(&entry->obj->lock); ww_acquire_fini(ctx); }
方法3在构建对象列表是临时的而不是提前构建时非常有用,例如在调整图中的边缘时,每个节点都有自己的ww_mutex锁,只有在持有所有涉及节点的锁时才能更改边缘。w/w互斥体对这种情况非常合适,原因如下:
- 它们可以以任何顺序处理锁获取,这使我们可以从起始点开始遍历图,然后迭代地发现新的边缘并锁定这些边缘连接到的节点。
- 由于-EALREADY返回代码表示给定对象已经被持有,因此无需额外的记录来打破图中的循环或跟踪已经持有的锁(当使用多个节点作为起始点时)。
请注意,这种方法与上述方法有两个重要的不同之处:
- 由于对象列表是动态构建的(并且由于命中-EDEADLK死锁条件而重试时可能会有所不同),因此在对象未被锁定时无需保留任何对象在持久列表上。因此,我们可以将list_head移入对象本身。
- 另一方面,动态对象列表构建也意味着-EALREADY返回代码无法传播。
还要注意,方法#1和#2和方法#3可以结合使用,例如首先使用上述方法之一锁定一组起始节点(从用户空间传入),然后使用方法#3锁定受操作影响的任何其他对象。回退/重试过程将会更加复杂,因为当动态锁定步骤遇到-EDEADLK时,我们还需要解锁使用固定列表获取的所有对象。但是w/w互斥体调试检查将捕获这些情况的任何接口误用。
另外,方法3在锁获取步骤中不能失败,因为它不返回-EALREADY。当然,在使用_interruptible变体时情况会有所不同,但这超出了这些示例的范围:
struct obj { struct ww_mutex ww_mutex; struct list_head locked_list; }; static DEFINE_WW_CLASS(ww_class); void __unlock_objs(struct list_head *list) { struct obj *entry, *temp; list_for_each_entry_safe (entry, temp, list, locked_list) { /* 在解锁之前需要这样做,因为只有当前锁持有者才能使用对象 */ list_del(&entry->locked_list); ww_mutex_unlock(entry->ww_mutex) } } void lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx) { struct obj *obj; ww_acquire_init(ctx, &ww_class); retry: /* 重新初始化循环起始状态 */ loop { /* 魔术代码,遍历图并决定要锁定哪些对象 */ ret = ww_mutex_lock(obj->ww_mutex, ctx); if (ret == -EALREADY) { /* 我们已经有了这个,转到下一个对象 */ continue; } if (ret == -EDEADLK) { __unlock_objs(list); ww_mutex_lock_slow(obj, ctx); list_add(&entry->locked_list, list); goto retry; } /* 锁定了一个新对象,将其添加到列表中 */ list_add_tail(&entry->locked_list, list); } ww_acquire_done(ctx); return 0; } void unlock_objs(struct list_head *list, struct ww_acquire_ctx *ctx) { __unlock_objs(list); ww_acquire_fini(ctx); }
方法4:仅锁定单个对象。在这种情况下,死锁检测和预防显然是多余的,因为仅仅获取一个锁就不可能在一个类中产生死锁。为了简化这种情况,可以使用带有NULL上下文的w/w互斥体API。
实施细节
设计:
ww_mutex目前封装了一个struct mutex,这意味着对于常见的互斥锁,没有额外的开销。因此,如果不使用等待/抢占互斥锁,代码大小只会略微增加。
我们对等待列表维护以下不变性:
- 拥有获取上下文的等待者按时间戳顺序排序;没有获取上下文的等待者按FIFO顺序交错排列。
- 对于Wait-Die,拥有上下文的等待者中只有第一个可能已经获取了其他锁(ctx->acquired > 0)。请注意,这个等待者可能会在列表中的其他没有上下文的等待者之后。
Wound-Wait抢占是通过一种延迟抢占方案实现的:只有在为新锁争用时(因此真正有死锁的可能性)才会检查事务的受伤状态。在这种情况下,如果事务受伤,它会退后一步,清除受伤状态并重试。以这种方式实现抢占的一个很大好处是,受伤的事务可以在重新启动事务之前识别一个竞争的锁来等待。盲目地重新启动事务可能会导致事务再次不得不退后。
一般情况下,不会有太多的争用。锁通常用于对设备资源进行串行访问,并且优化的重点应该放在无争用的情况下。
Lockdep:
我们特别注意警告尽可能多的API滥用情况。一些常见的API滥用情况将在CONFIG_DEBUG_MUTEXES下被捕获,但建议使用CONFIG_PROVE_LOCKING。
将会警告一些错误情况:
- 忘记调用ww_acquire_fini或ww_acquire_init。
- 在ww_acquire_done之后尝试锁定更多的互斥锁。
- 在-EDEADLK之后尝试锁定错误的互斥锁,并在解锁所有互斥锁之前。
- 在-EDEADLK之后尝试锁定正确的互斥锁,并在解锁所有互斥锁之前。
- 在返回-EDEADLK之前调用ww_mutex_lock_slow。
- 使用错误的解锁函数解锁互斥锁。
- 在同一上下文上两次调用ww_acquire_*。
- 在互斥锁上使用与ww_acquire_ctx不同的ww_class。
- 可能导致死锁的常规lockdep错误。
可能导致死锁的一些lockdep错误:
- 在第一个ww_acquire_ctx上调用ww_acquire_fini之前,调用ww_acquire_init初始化第二个ww_acquire_ctx。
- 可能发生的“正常”死锁。
待修复:
在实现TASK_DEADLOCK任务状态标志的魔法后,更新此部分。