用户空间和内核空间之间的共享信号量

23

简要版本

是否可以在用户空间和内核空间之间共享信号量(或任何其他同步锁)?命名的 POSIX 信号量具有内核持久性,这就是我想知道是否也可以从内核上下文中创建并/或访问它们的原因。

由于有关 POSIX 信号量正常使用的海量信息,因此在互联网上搜索并没有多大帮助。

详细版本

我正在开发一个实时系统的统一接口,其中我需要进行一些额外的簿记工作,并受到信号量保护。这些簿记工作是在资源分配和释放中完成的,而这些操作在非实时上下文中执行。

对于 RTAI,等待并发布信号量的线程需要处于实时上下文中。这意味着在用户空间的每个等待/发布过程中,在实时和非实时上下文之间切换,并且更糟糕的是,在内核空间为每个 sem/wait 创建一个短实时线程。

我正在寻找的是一种方法,在内核空间和用户空间之间共享普通的 Linux 或 POSIX 信号量,以便我可以在非实时上下文中安全地等待/发布。

对于这个问题的任何信息都将不胜感激。如果不可能,请问您是否有其他想法如何完成此任务?1

1其中一种方法是添加一个系统调用,在内核空间中拥有信号量,并让用户空间进程调用该系统调用,信号量将完全在内核空间中管理。如果不得不因此补丁内核,我会更加开心。


我想象用户端会涉及到一些上下文切换的复杂性...只是猜测而已。 - Drew McGowen
@DrewMcGowen,我猜测由于内核知道信号量,所以内核中应该有类似于“do_sem_wait”的函数(内核是否仍然具有内核端的“do_X”?)或者其他能够处理这些问题的东西。从用户的角度来看,一切都是普通的POSIX信号量。实际上,我只是试图从内核访问同一个POSIX信号量。 - Shahbaz
你在内核的哪里?你希望与调度程序共享用户空间信号量吗?你看到问题了吗?你必须更具体地说明内核空间。你不能在中断处理程序中使用up()down(),更不用说一些页面故障和其他复杂的内核空间了。 - artless noise
@artlessnoise,只是为了明确,我对中断处理程序不感兴趣。 - Shahbaz
我认为只有内核线程才有意义吧?很多子系统也是通过故障处理程序调用的。 - artless noise
@artlessnoise,是的,我对作为内核线程运行的代码感兴趣。如果你想称之为内核级别的“应用程序”,那也可以。 - Shahbaz
8个回答

15

你的方向是正确的,但不完全正确 -

Linux 命名的 POSIX 信号量基于 FUTex,即快速用户空间互斥锁。如其名称所示,尽管内核对其实现进行了协助,但其中很大一部分由用户代码完成。在内核和用户空间之间共享这样的信号量需要在内核中重新实现这个基础设施。这是可能的,但肯定不容易。

另一方面,SysV 信号量完全在内核中实现,只能通过标准系统调用(例如 sem_timedwait() 和相关函数)访问到用户空间。

这意味着每个 SysV 相关操作(信号量创建、获取或释放)实际上都是在内核中实现的,您可以从代码中简单地调用底层内核函数来获取相同的信号量。

因此,您的用户代码将仅调用 sem_timedwait()。这是容易的部分。

内核部分稍微有些棘手:您必须找到在内核中实现 sem_timedwait() 及其相关调用的代码(它们全部位于 ipc/sem.c 文件中),并创建每个函数的副本,这些函数执行与原始函数相同的操作,但没有调用 copy_from_user(...)copy_to_user(..) 等函数。

其原因是这些内核函数期望从具有用户缓冲区指针的系统调用中进行调用,而您想使用内核缓冲区中的参数来调用它们。

例如,考虑sem_timedwait() - 相关的内核函数是sys_timedwait()在ipc/sem.c中(请参见:这里:http://lxr.free-electrons.com/source/ipc/sem.c#L1537)。如果您将该函数复制到您的内核代码中,并仅删除执行copy_from_user()copy_to_user()的部分,然后只使用传递的指针(因为您将从内核空间调用它们),您将获得可以从内核空间和用户空间同时获取SysV信号量的内核等效函数-只要您在内核进程上下文中调用它们(如果您不知道最后一句话的含义,我强烈建议阅读《Linux设备驱动程序》第三版)。祝你好运。

这看起来非常有前途。通过“用户上下文内核”,您是否意味着作为系统调用结果运行的代码(例如,在/sys文件处理程序或ioctl中)或任何不在中断上下文中的内核代码?换句话说,如果您有一个正常的内核模块,__init函数是否在“用户上下文内核”中?那么kthread内部的代码呢? - Shahbaz
我在书中搜索了“用户上下文”,但没有找到太多相关内容。等我有更多时间时,我会进一步研究它。同时,如果您能稍微澄清一下,那就太好了。 - Shahbaz
@shahbaz 我指的是任何不在中断上下文中的内核代码。我可能应该使用术语:“进程上下文”。对于造成的混淆,我感到抱歉。 - gby
好的,那太棒了。我会在周末尝试一下。希望sysV信号量的实现是稳定的,这样我就不用考虑内核版本了,但这是我自己需要检查的事情。 - Shahbaz
我授予你的悬赏,因为它完全回答了问题。我需要一些时间来测试不同的方法,最终可能会采用更简单的方法。所以当我操作时,我会接受这个答案。感谢你的答案 :) - Shahbaz
显示剩余3条评论

6
我能想到的一个解决方案是,在主内核模块中有一个名为/proc(或 /sys 或其他)的文件,向其中写入0/1 ,或从其中读取/写入数据,则会导致其在信号量上发出up/down信号。导出该信号量允许其他内核模块直接访问它,而用户应用程序将通过/proc文件系统进行访问。
我仍然会等待看看原始问题是否有答案。

很遗憾,我认为这不会起作用,因为procfssysfs操作是非原子性的,并且使程序容易受到竞态条件的影响。 - Vilhelm Gray
@VilhelmGray,在虚拟文件的读/写处理程序中,我会调用updown。如果两个进程同时尝试从一个/proc文件中读取(例如,如果该文件不做任何操作),是否存在问题?如果没有,则不应存在竞争条件,因为实际的同步由良好工作的up/down函数完成。 - Shahbaz
抱歉,我误解了你的方法:我以为你是在读取一个 0/1,然后在用户空间调用 up/down。如果你完全从内核空间处理信号量,并且只使用 /proc 文件请求控制,则不应该有任何问题。 - Vilhelm Gray
最终我选择了sysfs,因为我更熟悉它,但ioctl也可以使用。 - Shahbaz
顺便提一下,据我上次尝试(内核版本为3.x),这里有一个陷阱:https://dev59.com/hofca4cB1Zd3GeqPeBWQ - Shahbaz

3
我并没有丰富的经验,但这是我的观点。如果您查看glibc的实现 sem_opensem_wait,它只需在/dev/shm中创建一个文件,从中映射结构,并使用原子操作对其进行操作。如果您想从用户空间访问命名信号量,则可能需要修补tmpfs子系统。但我认为这将很困难,因为不容易确定文件是否应该是命名信号量。
更简单的方法可能是仅重用内核的信号量实现,并使内核管理用户空间进程的信号量。为此,您将编写一个与设备文件相关联的内核模块。然后为设备文件定义两个ioctl,一个用于等待,一个用于发布。这是关于编写内核模块的好教程,包括设置设备文件并添加I/O操作。 http://www.freesoftwaremagazine.com/articles/drivers_linux。我不确切知道如何实现ioctl操作,但我认为您可以将函数分配给file_operations结构的ioctl成员。不确定函数签名应该是什么,但您可能可以通过挖掘内核源代码来找出答案。

感谢您指向glibc实现的指针。您的建议与我的答案非常相似(除了使用ioctl而不是sysfs)。我会认真考虑它。 - Shahbaz

2
我想用不同的方式回答这个问题:你不想这样做。没有界面来执行这种操作是有很好的理由的,所有其他内核子系统都被设计和实现为永远不需要在用户空间和内核空间之间共享锁。如果你开始玩弄可以阻止内核执行某些操作的用户空间,锁定顺序和意外出现的隐式锁定的复杂性将很快失控。
让我回忆一下我大约15年前进行的非常长的调试会话,至少可以阐明你可能遇到的复杂问题。我参与开发了一个文件系统,其中大部分代码位于用户空间。类似FUSE这样的东西。
内核将执行文件系统操作,将其打包成消息并发送给用户空间守护程序并等待回复。用户空间守护程序读取消息,执行操作并将回复写入内核,内核唤醒并继续操作。简单的概念。
你需要了解的一个关于文件系统的事情就是锁定。当你查找文件名时,例如“foo/bar”,内核以某种方式获取目录“foo”的节点,然后锁定它并询问它是否拥有文件“bar”。文件系统代码以某种方式找到“bar”,锁定它,然后解锁“foo”。锁定协议非常直接(除非你正在重命名),父节点始终在子节点之前被锁定,子节点在释放父节点锁之前被锁定。查找文件的消息将被发送到我们的用户空间守护程序,而目录仍然被锁定,当守护程序回复时,内核将继续首先锁定“bar”,然后解锁“foo”。
我甚至不记得我们调试的症状,但我记得问题并不是轻松重现的,需要数小时的文件系统 torture 程序才能显现出来。但经过几周的努力,我们弄清了发生了什么。假设我们文件的完整路径为“/a/b/c/foo/bar”。我们正在查找“bar”,这意味着我们持有“foo”的锁。守护程序是一个正常的用户空间进程,因此它执行的某些操作可能会阻塞并且也可能被抢占。它实际上通过网络进行通信,因此可能会长时间阻塞。当我们等待用户空间守护程序时,其他一些进程想要查找“foo”。为此,它已经锁定了“c”的节点,并询问它是否可以查找“foo”。它成功找到并尝试锁定它(在我们释放“c”的锁之前必须锁定它),并等待“foo”上的锁被释放。另一个进程进来想要查找“c”,当然会在持有“b”的锁时等待该锁。另一个进程等待“b”并持有“a”的锁。还有一个进程想要“a”并持有“/”的锁。
这不是一个问题,至少现在还不是。这种情况在普通的文件系统中也有时会发生,锁可能一直升级到根目录,你需要等待缓慢的磁盘一段时间,等到磁盘响应,拥塞减轻后,每个人都能获得他们的锁,一切都运行正常。然而,在我们的情况下,长时间持有锁的原因是因为我们分布式文件系统的远程服务器没有响应。X秒钟后,用户态守护程序超时,并且在回复内核"bar"查找操作失败之前,记录了一个带有时间戳的syslog消息。时间戳需要的一件事是时区信息,因此它需要打开"/etc/localtime",当然,为了做到这一点,它需要开始查找"/etc",为此它需要锁定"/"。"/"已被其他人锁定,因此用户态守护程序等待该其他人解锁"/",而该其他人通过五个进程和锁等待守护进程响应。系统最终陷入完全死锁状态。
也许您的代码不会出现这样的问题。您谈论的是实时系统,因此可能存在一定的控制水平,普通内核没有这种控制水平。但我不确定添加意外的锁定复杂性是否会使您保持系统的实时特性,或者确保您在用户空间中执行的任何操作都永远不会创建死锁级联。如果您不进行页面、不触摸任何文件描述符、不执行内存操作以及其他一些我现在无法想到的操作,您可以通过用户态和内核之间共享锁来避免问题,但这将很困难,您可能会发现意想不到的问题。

谢谢你的回答,很有启发性。不过我认为这种情况不会发生。实时部分已经使用了RTAI锁定机制,可以在两个空间之间很好地共享,我以前广泛使用过,没有问题。这个问题中的锁是用于软件的非实时簿记部分,它基本上总是以以下形式存在:lock-access shared memory-unlock - Shahbaz
唯一可能出现的问题是,如果我在关键部分内调用一个函数,该函数再次尝试锁定互斥体,这将是一个问题,无论我是否在用户空间和内核空间之间共享它,或者只是其中之一。此外,内核空间部分与用户空间部分没有什么区别。没有中断处理或任何用户空间进程无法执行的操作。唯一的区别是,就RTAI而言,在内核空间运行的相同硬实时应用程序具有更小的实时操作延迟。 - Shahbaz
嗯,我不知道你的具体情况。你可能可以逃脱惩罚。我只是想描述一下为什么通常不这样做。请记住,用户进程在持有锁时可能会遇到内存管理锁(故障),内核端可能会遇到其他意外的锁(中断)。当用户空间崩溃时(退出涉及大量锁定)并转储核心(文件系统锁定),信号处理程序(更多意外锁定)等,都要记得解锁共享锁,以便在用户空间意外退出时不会阻塞内核。 - Art
我会重新设计通信协议,使用循环无锁缓冲区,或者普通管道之类的东西。但这只是我比较保守的想法。 - Art
我不介意采用更好的设计,但我不知道如何使用循环缓冲区或管道来管理简单的关键部分访问。共享信号量实际上只是一个互斥锁,具有基本的“锁定-访问-解锁”模式。 - Shahbaz
观察Art的话可以有另外一种解释:你将如何应对饥饿问题?例如,用户空间可能会获取信号量并由于更高优先级的进程而被抢占。现在内核空间会挨饿吗?会发生优先级倒置吗?如果内核空间/线程始终比用户空间/线程具有更高的优先级,那么用户空间该如何应对饥饿问题? - lsk

2

我相信您知道,即使最好的解决方案也很可能非常丑陋。如果我处在您的位置,我会简单地放弃这场战斗并使用“约会点”来同步进程。


这是避免实现适当解决方案的不好原因。实际上,一个有效的解决方案甚至可能并不难实现。我可以随口想到一个可能的优雅解决方案:也许使用 mmap 在用户和驱动程序之间共享内存,从而可以在两侧使用相同的内存来读写原子值(即与信号量交互)。 - Vilhelm Gray

2
我已经阅读了你的项目README,并有以下观察。提前道歉:
首先,已经存在一个通用的实时系统接口,它被称为POSIX;肯定VxWorks、Integrity和QNX都符合POSIX标准,并且根据我的经验,如果你在POSIX API中开发,可移植性问题很少。无论POSIX是否明智,但这是我们所有人都使用的。
[大多数RTOS符合POSIX的原因是它们的主要市场之一是国防设备。美国国防部不允许你在他们的非IT设备(如雷达)上使用非POSIX兼容的操作系统......这基本上使得如果没有给RTOS添加POSIX,就几乎不可能商业化。]
其次,通过应用PREMPT_RT补丁集,Linux本身可以成为一个相当不错的实时操作系统。在所有实时操作系统中,从充分利用所有多核CPU的角度来看,这可能是目前最好的一个。然而,它并不像其他操作系统那样是硬实时操作系统,因此需要权衡利弊。
RTAI采取了一种不同的方法,实际上将自己的实时操作系统放在Linux下面,并使Linux成为运行在他们操作系统中的一个任务。这种方法在某种程度上还可以,但是RTAI的一个很大的缺点是,实时部分现在(据我所知)符合POSIX标准(尽管API看起来只是在一些POSIX函数名前面加上了rt_),与其他事物的交互现在变得非常复杂,正如你正在发现的那样。
PREEMPT_RT是比RTAI更具侵入性的补丁集,但回报是其他所有东西(如POSIX和valgrind)仍然完全正常。此外,像FTrace这样的好东西也可用。然后,记账仅涉及使用现有工具,而不必编写新工具。此外,看起来PREEMPT_RT正在逐渐蠕动进入主流Linux内核。这将使其他补丁集(如RTAI)几乎毫无意义。

因此,Linux + PREEMPT_RT为我们提供了实时POSIX以及一堆工具,就像其他所有RTOS一样;跨越全行业的共同点。这有点像您项目的目标。

很抱歉我没有帮助您的项目“如何”问题,而且我询问“为什么?”也非常不绅士。但我觉得知道已经存在的东西似乎与您试图做的事情重叠很重要。推翻POSIX之王将是困难的。


1
我了解你所说的大部分内容,但还是感谢你的努力。首先,关于PREEMPT_RT,正如你所说,“它并不完全是实时的”,在实时世界中这意味着“不是实时的”。也许在一般情况下这样的系统可以胜任,但是对于关键应用程序而言,如果没有任何保证,就不能依赖它。 - Shahbaz
1
关于POSIX,我知道它是无处不在的,但是有一些问题需要解决。首先,我需要以某种方式将RTAI集成进去,但它们之间并不兼容(RTAI基本上是Linux唯一的真正实时功能)。其次,POSIX非常不规则,例如创建共享信号量和共享读写锁的方式完全不同。一个漂亮而干净的API可能会很有吸引力(至少对我来说是这样)。 - Shahbaz
1
第三,有一些功能我宁愿立即可用。例如:get_and_reserve_an_unused_name()。目前,大多数实时人员在实践中处理非常基本的问题。他们的系统高度静态且小型,他们不考虑错误恢复等。POSIX 可能适合这种情况。我试图通过 URT 构建一个子结构,以允许更具动态性和容错性的应用程序。如果您愿意,可以称其为未来主义。 - Shahbaz
PREEMPT_RT确实非常不错,我了解到它在最大延迟等方面与RTAI相差不远。是的,POSIX 确实很笨重,但这就是我们所拥有的。尝试做一些新的东西的风险在于它可能会成为支持的噩梦。对于像我这样的应用程序开发人员来说,使用除POSIX之外的东西是一个很大的风险。 - bazza
你说你正在努力让应用程序更具动态性和容错性?听起来很有趣。你所说的“故障”是指无法满足实时需求吗?我经常实现“下一个可用作业分配系统”,这在某种程度上是“容错”的,因为处理资源是根据已经发生的情况即时选择的。将该模式框架化/库化将非常有趣!我避免在运行时创建/分配资源--这总是需要时间的,因此你关于“高度静态”的部分是正确的。 - bazza
显示剩余6条评论

1
在Linux/GLIBC中存在多种解决方案,但没有一种允许在用户空间和内核空间之间显式共享信号量。 内核提供了挂起线程/进程的解决方案,最有效的是futex。以下是当前同步用户空间应用程序的现有实现的最新情况的一些详细信息。
SYSV服务
Linux System V(SysV)信号量是同名Unix OS的遗留物。它们基于系统调用来锁定/解锁信号量。相应的服务包括:
- semget() 获取标识符 - semop() 对信号量执行操作(例如增加/减少) - semctl() 对信号量进行某些控制操作(例如销毁)
GLIBC(例如 2.31版本)在这些服务之上没有提供任何附加值。库服务直接调用同名系统调用。例如,semop()(位于sysdeps/unix/sysv/linux/semtimedop.c中)直接调用相应的系统调用:
int
__semtimedop (int semid, struct sembuf *sops, size_t nsops,
          const struct timespec *timeout)
{
  /* semtimedop wire-up syscall is not exported for 32-bit ABIs (they have
     semtimedop_time64 instead with uses a 64-bit time_t).  */
#if defined __ASSUME_DIRECT_SYSVIPC_SYSCALLS && defined __NR_semtimedop
  return INLINE_SYSCALL_CALL (semtimedop, semid, sops, nsops, timeout);
#else
  return INLINE_SYSCALL_CALL (ipc, IPCOP_semtimedop, semid,
                  SEMTIMEDOP_IPC_ARGS (nsops, sops, timeout));
#endif
}
weak_alias (__semtimedop, semtimedop)

现在,SysV信号量(以及其他SysV IPC,如共享内存和消息队列)被视为过时,因为它们每次操作都需要进行系统调用,会通过系统上下文切换减缓调用进程的速度。新应用程序应使用GLIBC提供的符合POSIX标准的服务。
POSIX信号量基于快速用户互斥锁(FUTEX)。其原理是,在没有争用的情况下,使用原子操作在用户空间中递增/递减信号量计数器。但是当存在争用(多个线程/进程同时想要“锁定”信号量)时,将执行futex()系统调用,以便在“解锁”信号量时唤醒等待的线程/进程或暂停等待释放信号量的线程/进程。从性能角度来看,与上述SysV服务相比,这产生了很大的差异,后者对任何操作都需要系统调用。 POSIX服务在GLIBC中实现了用户空间部分操作(原子操作),仅在存在争用时才切换到内核空间。
例如,在GLIBC 2.31中,锁定信号量的服务位于nptl/sem_waitcommon.c中。它检查信号量的值,并使用原子操作将其减少(在__new_sem_wait_fast()中),并调用futex()系统调用(在__new_sem_wait_slow()中)来挂起调用线程,仅当尝试将其减少之前,信号量的值等于0时。
static int
__new_sem_wait_fast (struct new_sem *sem, int definitive_result)
{
[...]
  uint64_t d = atomic_load_relaxed (&sem->data);
  do
    {
      if ((d & SEM_VALUE_MASK) == 0)
    break;
      if (atomic_compare_exchange_weak_acquire (&sem->data, &d, d - 1))
    return 0;
    }
  while (definitive_result);
  return -1;
[...]
}
[...]
static int
__attribute__ ((noinline))
__new_sem_wait_slow (struct new_sem *sem, clockid_t clockid,
             const struct timespec *abstime)
{
  int err = 0;

[...]
  uint64_t d = atomic_fetch_add_relaxed (&sem->data,
      (uint64_t) 1 << SEM_NWAITERS_SHIFT);

  pthread_cleanup_push (__sem_wait_cleanup, sem);

  /* Wait for a token to be available.  Retry until we can grab one.  */
  for (;;)
    {
      /* If there is no token available, sleep until there is.  */
      if ((d & SEM_VALUE_MASK) == 0)
    {
      err = do_futex_wait (sem, clockid, abstime);
[...]

基于futex的POSIX服务的示例包括:

要管理互斥锁(即二进制信号量),可以使用pthread服务。它们也基于futex。例如:


我阅读了你的回答,但没有找到关于如何在用户空间和内核空间之间“共享”信号量的内容。我有什么遗漏了吗?还是你试图表明SysV已经过时,因为它需要系统调用? - Louis Go
@LouisGo: 你没错过任何东西。我想指出的事实是,Linux/GLIBC中已经存在多种解决方案,但没有一种允许在用户和内核之间显式共享信号量的解决方案。内核提供了挂起线程/进程的解决方案,最有效的是futex。 - Rachid K.
你能否把结论放在答案的开头?这将有助于未来的用户。根据您的描述,任何使用futex的技术都不允许用户空间和内核之间的通信。因此,试图找到解决方案的人会知道在这种情况下futex不起作用。 - Louis Go

0

我在思考内核和用户空间直接共享东西的方法,即不需要系统调用/复制成本。我记得的一件事是RDMA模型,其中内核直接从用户空间写入/读取,当然要进行同步。您可以探索该模型并查看它是否适合您的目的。


可能会,但更困难的部分可能是用户空间应用程序如何锁定内核空间应用程序。 - Shahbaz
如果我没记错的话,有关门铃、注册回调函数以及同步等方面的内容。你可能需要深入了解详情。 - lsk
有趣。我一定会研究一下。 - Shahbaz
RDMA是_Remote DMA_的缩写吗?维基百科上说,它表示在一台计算机的内存和另一台计算机之间进行直接内存访问。这是你建议我研究的内容吗?还是指的是另一个RDMA? - Shahbaz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接