• Feeds

  • Posts Tagged ‘Linux’


    谈Linux内核定时器

    看了3个周末的Linux内核,包括前两周总结的进程与线程锁与同步。当时研究这个的原因是因为某个程序CPU context switch过高。不过到现在相关的背景知识也了解了,问题也早已经解决了,所以暂时也没有进一步的兴趣驱动去研究内核了,这里是终结篇。:)

    在应用程序运行的时候,特别是抢占式的操作系统里面,内核怎么样取得控制权?这依赖硬件支持的系统定时器来实现。内核中有非常多的功能由定时器触发。系统定时器以固定的频率触发,这个频率称为tick rate(HZ),每个触发周期的时间叫做tick, 它等于1/HZ秒。触发后系统会转到内核相应的handler去处理。

    在系统定时器中断时候内核需要做以下事情

    • 更新uptime, time of day, resource及处理器使用状态
    • 检查多处理器是否负载均衡
    • 检查当前进程是否timeslice是否用完,如果用完则重新分配下阶段计划(reschedule)
    • 运行应用程序自定义的定时器(dynamic timers)

    了解了tick rate之后才能比较好理解为什么sleep(), timer等API声称都不是提供精确的时间,因为cpu一个任务timeslice没运行完,系统是不会马上切换到这些dynamic timer的进程的。

    Linux 2.5以上将tick从100改成1000,相当于系统时钟从10ms变成每1ms要中断一次,当时也引起较大的争议。

    改成1000的优点有:

    • 系统时钟精度更高更准确。
    • 系统调用如poll(), select() 等依赖系统时钟的调用性能得到很大改善。
    • 内核实时运行指标如资源占用,uptime更准确。
    • 抢占式机制更精确。

    缺点:
    我们知道定时器频繁被触发必将会增加开销,降低throughput。这是很多人反对改成1000的理由。原理上它在CPU的时间中断上将会增加10倍的开销。但由于现代CPU硬件速度的变快。大家也逐渐认可了新的调整不会给整体性能带来大的影响。如果在意这一点的可以重新编译内核, 将<asm/param.h>中HZ的值由1000降低。目前可以接受的值有100,500和1000。

    Linux 2.6.21 又增加了一种叫dyntick的技术,dyntick就是在系统空闲的时候,彻底停止时间中断, 避免cpu空转,它会带来节能方面的好处。详情可参看Clockevents and dyntick。这是个新的技术,一般的书上都没来得及写进去。

    比较感兴趣的后续课题是virtualization系统中的timer实现。

    了解Linux的锁与同步

    上周看了Linux的进程与线程,对操作系统的底层有了更进一步的一些了解。我同时用Linux内核设计与实现Solaris内核结构两本书对比着看,这样更容易产生对比和引发思考。现代操作系统很多思路都是相同的,比如抢占式的多线程及内核、虚拟内存管理等方面。但另外一方面还是有很多差异。在了解锁和同步之前,原子操作是所有一切底层实现的基础。

    原子操作Atomic

    通常操作系统和硬件都提供特性,可以对一个字节进行原子操作的的读写,并且通常在此基础上来实现更高级的锁特性。

    • atomic_t结构

    原子操作通常针对int或bit类型的数据,但是Linux并不能直接对int进行原子操作,而只能通过atomic_t的数据结构来进行。目前了解到的原因有两个。

    一是在老的Linux版本,atomic_t实际只有24位长,低8位用来做锁,如下图所示。这是由于Linux是一个跨平台的实现,可以运行在多种 CPU上,有些类型的CPU比如SPARC并没有原生的atomic指令支持,所以只能在32位int使用8位来做同步锁,避免多个线程同时访问。(最新版SPARC实现已经突破此限制)

    atomic_t

    另外一个原因是避免atomic_t传递到程序其他地方进行操作修改等。强制使用atomic_t,则避免被不恰当的误用。

    atomic_t my_counter = ATOMIC_INIT(0);
    val = atomic_read( &my_counter );
    atomic_add( 1, &my_counter );
    atomic_inc( &my_counter );
    atomic_sub( 1, &my_counter );
    atomic_dec( &my_counter );
    • 原子操作硬件上的实现

    Solaris的实现是基于test-and-set的指令,并且该指令为原子操作。比如Solaris的实现在SPARC上是基于ldstub和cas指令,在x86上用的是cmpxchg指令。但是Linux似乎直接用的add指令。

    OpenSolaris i386的实现

    	movl	4(%esp), %edx	/ %edx = target address
    	movl	(%edx), %eax	/ %eax = old value
    1:
    	leal	1(%eax), %ecx	/ %ecx = new value
    	lock
    	cmpxchgl %ecx, (%edx)	/ try to stick it in
    	jne	1b
    	movl	%ecx, %eax	/ return new value
    	ret

    在Linux源代码asm_i386/atomic.h中

    /**
     * atomic_add - add integer to atomic variable
     * @i: integer value to add
     * @v: pointer of type atomic_t
     *
     * Atomically adds @i to @v.  Note that the guaranteed useful range
     * of an atomic_t is only 24 bits.
     */
    static __inline__ void atomic_add(int i, atomic_t *v)
    {
            __asm__ __volatile__(
                    LOCK "addl %1,%0"
                    :"=m" (v->counter)
                    :"ir" (i), "m" (v->counter));
    }
    

    锁的类型

    • Spinlocks自旋锁

    如果锁被占用,尝试获取锁的线程进入busy-wait状态,即CPU不停的循环检查锁是否可用。自旋锁适合占用锁非常短的场合,避免等待锁的线程sleep而带来的CPU两个context switch的开销。

    • Semaphores信号量

    如果锁被占用,尝试获取锁的线程进入sleep状态,CPU切换到别的线程。当锁释放之后,系统会自动唤醒sleep的线程。信号量适合对锁占用较长时间的场合。

    • Adaptive locks自适应锁

    顾名思义,自适应锁就是上面两种的结合。当线程尝试申请锁,会自动根据拥有锁的线程繁忙或sleep来选择是busy wait还是sleep。这种锁只有Solaris内核提供,Linux上未见有相关描述。

    几种锁的性能比较(Windows操作系统下,第一种类似atomic_inc, 2,3类似spinlock, 4,5类似semaphore)

    7357
    (图片来源:Intel Software Network)

    Java synchronization

    再理论联系实际一下,看Java中的锁底层如何实现的。这篇关于JVM的Thin Lock, Fat Lock, SPIN Lock与Tasuki Lock中讲到Java synchronization实际上也是一种自适应锁。

    于是,JVM早期版本的做法是,如果T1, T2,T3,T4…产生线程竞争,则T1通过CAS获得锁(此时是Thin Lock方式),如果T1在CAS期间获得锁,则T2,T3进入SPIN状态直到T1释放锁;而第二个获得锁的线程,比如T2,会将锁升级(Inflation)为Fat Lock,于是,以后尝试获得锁的线程都使用Mutex方式获得锁。

    Java AtomicInteger的实现,似乎和Solaris的实现非常类似,也是一个busy wait的方式

       /**
         * Atomically increments by one the current value.
         *
         * @return the updated value
         */
        public final int incrementAndGet() {
            for (;;) {
                int current = get();
                int next = current + 1;
                if (compareAndSet(current, next))
                    return next;
            }
        }
    
        /**
         * Atomically sets the value to the given updated value
         * if the current value {@code ==} the expected value.
         *
         * @param expect the expected value
         * @param update the new value
         * @return true if successful. False return indicates that
         * the actual value was not equal to the expected value.
         */
        public final boolean compareAndSet(int expect, int update) {
            // unsafe.compareAndSwapInt是用本地代码实现
            return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
        }

    Solaris

    感觉OpenSolaris在很多地方要比Linux优秀,Solaris在理论设计和实践上都非常优雅,而Linux内核很多地方似乎更偏工程实践方向一些。另外Solaris用来做学习操作系统更合适,它的mdb几乎无所不能。

    我在VirtualBox虚拟机上安装了OpenSolaris,非常容易安装,使用这个Minimal OpenSolaris Appliance OVF image for VirtualBox 2.2 简易方法,安装一个没有gui的版本,大约3分钟以内就可以装好。OpenSolaris安装软件和Ubuntu一样方便,使用 pkg install SUNWxxx 的命令,比如 pkg install SUNWcurl

    了解Linux的进程与线程

    上周碰到部署在真实服务器上某个应用CPU占用过高的问题,虽然经过tuning, 问题貌似已经解决,但我对tuning的方式只是基于大胆的假设并最终生效了。我更希望更多的求证一下程序背后CPU及OS kernel当时的运作机制。所以我读了一些Linux内核设计与实现及其他一些相关资料,对Linux process的机制与切换有了更多一些体会。本文尽可能条理一点,但由于牵涉点较多,同时自己可能觉得某些点有记录的价值,因此文字可能会零散。

    • 进程状态

    Linux进程的状态比较容易理解,值得注意的是 UNINTERRUPTIBLE 及 ZOMBIE

    TASK_RUNNING
    TASK_INTERRUPTIBLE
    TASK_UNINTERRUPTIBLE 此时进程不接收信号,这就是为什么有时候kill一个繁忙的进程没有响应。
    TASK_ZOMBIE 我们经常 kill -9 pid 之后运行ps会发现被kill的进程仍然存在,状态为 zombie。zombie的进程实际上已经结束,占用的资源也已经释放,仅由于kernel的相关进程描述符还未释放。
    TASK_STOPPED

    • Kernel space and user space

    Kernel space是供内核,设备驱动运行的内存区域。user space是供普通应用程序运行的区域。每一个进程都运行在自己的虚拟内存区域,不能访问其他进程的内存空间。普通进程不能访问kernel space, 只能通过系统调用来间接进行。当系统内存比较紧张时,非当前运行进程user space可能会被swap到磁盘。

    使用命令 pmap -x <pid> 可以查看进程的内存占用信息; lsof -a -p <pid> 可以查看一个进程打开的文件信息。ps -Lf <pid> 可以查看进程的线程数。

    另外procfs也是一个分析进程结构的好地方。procfs是一个虚拟的文件系统,它把系统中正在运行的进程都显现在/proc/<pid>目录下。

    • 进程创建

    进程创建通常调用fork实现。创建后子进程和父进程指向同一内存区域,仅当子进程有write发生时候,才会把改动的区域copy到子进程新的地址空间,这就是copy-on-write技术,它极大的提高了创建进程的速度。

    • Linux的线程实现

    Linux线程是通过进程来实现。Linux kernel为进程创建提供一个clone()系统调用,clone的参数包括如 CLONE_VM, CLONE_FILES, CLONE_SIGHAND 等。通过clone()的参数,新创建的进程,也称为LWP(Lightweight process)与父进程共享内存空间,文件句柄,信号处理等,从而达到创建线程相同的目的。

    Linux 2.6的线程库叫NPTL(Native POSIX Thread Library)。POSIX thread(pthread)是一个编程规范,通过此规范开发的多线程程序具有良好的跨平台特性。尽管是基于进程的实现,但新版的NPTL创建线程的效率非常高。一些测试显示,基于NPTL的内核创建10万个线程只需要2秒,而没有NPTL支持的内核则需要长达15分钟。

    在Linux 2.6之前,Linux kernel并没有真正的thread支持,一些thread library都是在clone()基础上的一些基于user space的封装,因此通常在信号处理、进程调度(每个进程需要一个额外的调度线程)及多线程之间同步共享资源等方面存在一定问题。为了解决这些问题,当年IBM曾经开发一套NGPT(Next Generation POSIX Threads), 效率比 LinuxThreads有明显改进,但由于NPTL的推出,NGPT也完成了相关的历史使命并停止了开发。

    NPTL的实现是在kernel增加了futex(fast userspace mutex)支持用于处理线程之间的sleep与wake。futex是一种高效的对共享资源互斥访问的算法。kernel在里面起仲裁作用,但通常都由进程自行完成。

    NPTL是一个1×1的线程模型,即一个线程对于一个操作系统的调度进程,优点是非常简单。而其他一些操作系统比如Solaris则是MxN的,M对应创建的线程数,N对应操作系统可以运行的实体。(N<M),优点是线程切换快,但实现稍复杂。

    • 信号

    进程接收信号有两种:同步和异步。同步信号比如SEGILL(非法访问), SIGSEGV(segmentation fault)等。发生此类信号之后,系统会立即转到内核陷阱处理程序,因此同步信号也称为陷阱。异步信号如kill, lwp_kill, sigsend等调用产生的都是,异步信号也称为中断。

    kill <pid> 调用的是 SIGTERM, 此信号可以被捕获和忽略。

    kill -9 <pid> 调用的是 SIGKILL, 杀掉进程,不能被捕获和忽略。

    SIGHUP是在终端被断开时候调用,如果信号没有被处理,进程会终止。这就是为什么突然断网刚通过远程终端启动的进程都终止的原因。防止的方法是在启动的命令前加上 nohup 命令来忽略 SIGHUP信号。如 nohup ./startup.sh &

    很多应用程序通常捕获SIGHUP用来实现一些自定义特性,比如通过控制台传递信号让正在运行的程序重新加载配置文件,避免重启带来的停止服务的副作用。可惜的是,在JAVA中没法直接使用这一功能,SUN JVM没有官方的signal支持,尽管它已经可以实现,详情可参看Singals and Java.

    另外有个有趣的现象是 zombie 状态的进程 kill/kill -9 都没有任何作用,这是由于进程本身已经不存在,所以没有相应的进程来处理signal, zombie状态的进程只是kernel中的进程描述符及相关数据结构没有释放,但进程实体已经不存在了。

    关于僵尸进程,也可参看下酷壳上的这篇Linux 的僵尸(zombie)进程,从程序的角度解释了相关原理。