今天条友@xiamenuser给我提了一个关于操作系统的问题:怎么把定时器线程里的回调函数,(在定时器触发之后)挪到工作线程里运行?
这个需求要做的事,跟Linux内核的信号机制是一样的。
OS内核的信号机制,在1970年的Unix时代就有了,是一个上古话题。
在unix里,可以使用kill -9 pid命令杀掉进程(pid为进程号),在Linux里也可以。
1.OS内核的信号
有个专有的宏定义#define SIGKILL 9,然后信号9就成了一个特别牛的信号,大概除了0号idle进程和1号init进程之外,其他进程都可以杀死。
0号进程和1号进程是不能杀死的,否则系统就崩溃了!
OS内核里对应着kill命令的sys_kill()系统调用,大概是上面这样:
在进程的task结构体的sigmap成员变量上,设置1个标志位,进程就可以收到信号了。
每个进程,在OS内核里都被一个task结构体表示,这个结构体的其中一个成员变量就是记录信号的:我们给他起名叫sigmap,Linux的不一定要叫这个名字,但肯定有这一项。
这个信号在什么时候处理呢?
等到收信号的进程下一次被调度运行的时候。
当前运行的进程,肯定是发信号的进程,否则它没法主动发起kill()系统调用。
发信号的进程做的事,只是把信号设置到接收进程的信号图上,这时信号实际上已经发到了:但是接收进程并不会马上因为SIGKILL信号而被杀死。
SIGKILL信号的杀进程,实际上进程是自杀的!
当收到信号的进程再次被调度运行的时候,操作系统会让它先执行信号的处理函数,而SIGKILL的处理函数,就是exit()系统调用:进程退出。
这个过程可以是异步的,等到接收进程下一次被调度时再处理,至于什么时候轮到它:等吧。
也可以让它马上同步处理,只需要在sys_kill()函数的末尾加一行代码就行:
直接选择接收进程是下一个要调度的进程,并且马上调度它运行:接下来它就完事了。
不需要等OS内核统计时间片,确定调度的优先级了,既然用户想让它挂掉,OS当然要马上让它挂掉。
毕竟Linux系统也惹不起用户啊,用户是可以重装windows的
接下来,说说shedule_task()之后的细节。
2.信号是怎么处理的
每个信号都有一个处理函数,叫信号处理函数。
信号处理函数,是在用户态的代码里运行的。
所以,程序员可以自己给部分信号编写处理函数,用signal()系统调用注册到OS内核,就可以(在收到信号时)运行这个自己编写的函数了。
如果信号处理函数是在内核状态运行的,那显然用户编写的函数是没法运行的,因为用户函数的内存地址在用户空间(它在进程的代码段里)。
OS内核在信号处理时要做的是,把进程从内核返回后要运行的代码地址,改成信号处理函数的地址。
修改过程如下:
1)进程从内核返回时的状态,如上图。
内核栈上的寄存器排布顺序不一定是对的,这要查intel的手册,但是这些项肯定都有。
在进程使用iret指令(中断返回)从内核返回的那一刻,内核栈上的这些数据都要弹出到对应的寄存器。
然后,进程就会运行EIP指向的用户代码,同时用户态的栈顶就是ESP。
EIP和ESP指向的内容到底是什么,内核不需要管:这是由程序员写代码时确定的。
进程从内核返回之后的错误,错的是程序员,不是系统内核。
但要是返不回来,或者不能处理信号,错的就是系统内核了。
2)OS内核要做的是,修改内核栈上、保存的、用户态的、EIP和ESP(注意这3个定语):
A,让EIP指向信号处理函数,
B,让ESP指向信号处理函数的参数,
C,在信号处理函数的下方,放上“真正的”返回地址,
D,在信号处理函数运行完之后,丢掉(信号处理函数的)参数,弹出真正的返回地址:让程序恢复正常的状态,继续运行。
如上图中的绿字部分。
如果一次要处理多个信号的话,就顺着用户栈继续叠加就行。
siska内核demo里的信号处理代码,如下的3张图:
因为信号处理函数有参数,而参数要压在用户态的栈上,所以信号处理函数运行完之后还要清理它。
所以,与一般的C函数不同,信号处理函数是被调函数清理堆栈的:即它是pascal调用,而不是C调用!
C调用,都是主调函数清理堆栈的。
所以,信号处理函数的总入口是一段汇编代码,用来在C语言里完成这个pascal调用。
这么看来,pascal这种老语言,也不是想象的那么差
这个信号处理方式,是我给出来的解决方案
至于Linux是不是也这么做的,我就不知道了。
但是,这么做是可行的。
上图95行的call *(%eax),就是调用信号处理的函数指针。
它前后的汇编代码,都是准备参数和清理堆栈。
3.回到开头的问题
怎么让定时器线程在触发之后,让回调函数在工作线程里运行?
回调函数一般有一个参数,表示回调上下文,但没有返回值。
因为定时器的添加和处理在2个线程里,回调函数的返回值没有意义。
如果回调函数的处理出错了,就在上下文里设置错误码作为提示。
所以,它的函数声明是这样的:void callback(void* ctx);
要让它正常运行,必须把回调上下文的指针添加到工作线程的用户栈上,同时让工作线程的内核栈上保存的EIP指向回调函数。
这个处理方式,与OS内核的信号处理方式是一样的。
信号处理函数的声明:void sighandler(int sig); 也是一个参数、无返回值。
在定时器触发之后,定时器线程可以发起一个系统调用,把这些信息给到内核,然后内核修改工作线程的数据,让定时器的回调处理“像个信号”一样就可以了
这个系统调用如果Linux没有提供的话,就只能自己修改Linux内核代码,或者给Linus大牛提个需求了(他有可能看不过来你的邮件)。
PS:
工作线程和定时器线程在同一个进程里,所以它们的用户态内存的代码段、数据段、堆都是共享的,只是内核栈和用户栈不一样。
内核栈:在内核看来,每个线程也是一个可调度的进程,它必须有自己的内核栈和页表。
同一个进程的不同线程之间共享内存,靠的是页表的映射:把它们映射到同一个物理内存页上。
用户栈:不同的线程可以并发运行,它们的用户栈肯定是不同的,否则局部变量就互相覆盖了:这肯定是不可能的。
siska里信号处理的代码,如下: