首页 > 操作系统 > 线程的启动

线程的启动

2010年1月18日 发表评论 阅读评论

  任何线程在同一个进程中都可以创建另一个线程,这没有任何的限制。创建线程最常用的就是POSIX函数pthread_create(),该函数的定义如下:

#include <pthread.h>

int
pthread_create (pthread_t *thread,
                const pthread_attr_t *attr,
                void *(*start_routine) (void *),
                void *arg);

  函数pthread_create()有四个参数:

  thread  一个指向pthread_t的指针,在那里保存着线程的ID;

  attr  一个属性结构;

  start_routine  线程要开始执行的程序;

  arg  要传递给线程的执行函数的参量。

  在这个函数里面,线程指针以及属性结构都是可选参数,你可以给它们传递NULL。

  thread参数可以用来保存新创建的线程的ID。在下面的例子你可能会注意到我们传递了NULL到这个参数,表示我们不关心这个新建的线程的ID。如果关心的话,可以像下面这样做:

pthread_t tid;

pthread_create (&tid, …
printf ("Newly created thread id is %d\n", tid);

  这是常用的做法,因为有些时候你可能想知道哪个线程运行了哪段代码。

  新的线程是通过使用参量arg的start_routine()的开始执行那个而开始的。

线程属性结构(thread attributes structure)

  当启动新线程时,你可以设定它的一些特性或让其按照默认特性运行。在开始讨论线程属性函数之前,先看看pthread_attr_t数据类型:

typedef struct {
    int                 __flags;
    size_t              __stacksize;
    void                *__stackaddr;
    void                (*__exitfunc)(void *status);
    int                 __policy;
    struct sched_param  __param;
    unsigned            __guardsize;
} pthread_attr_t;

  基本上,数据结构中的各个元素解释如下:

__flags 非数值量而为布尔量(例如,线程应该是分离的还是可连接的)

__stacksize, __stackaddr, and __guardsize  堆栈的定义

__exitfunc  当线程退出时要执行的函数

__policy and __param  线程调度参数

  可以对线程操作的函数如下:

属性管理

pthread_attr_destroy()
pthread_attr_init()

标志(布尔量特性)

pthread_attr_getdetachstate()
pthread_attr_setdetachstate()
pthread_attr_getinheritsched()
pthread_attr_setinheritsched()
pthread_attr_getscope()
pthread_attr_setscope()

堆栈相关

pthread_attr_getguardsize()
pthread_attr_setguardsize()
pthread_attr_getstackaddr()
pthread_attr_setstackaddr()
pthread_attr_getstacksize()
pthread_attr_setstacksize()
pthread_attr_getstacklazy()
pthread_attr_setstacklazy()

线程调度相关
pthread_attr_getschedparam()
pthread_attr_setschedparam()
pthread_attr_getschedpolicy()
pthread_attr_setschedpolicy()

  一共有大约20个函数,不过在实际使用中我们只要关心一半就行了,因为这些函数都是成对出现的:get与set(只有pthread_attr_init()和pthread_attr_destory()两个函数例外)。

  在研究这些属性函数之前,需要注意一点。在使用属性结构之前,需要先调用pthread_attr_init()函数来先初始化这个结构,为其设置合适的属性参数之后再调用pthread_create()函数来创建线程。在线程已经创建之后再修改属性结构是没有任何效果的。

线程属性管理

  在线程属性结构使用之前需要先调用pthread_attr_init()函数对其初始话,如下面的代码所示:

…

pthread_attr_t  attr;
…
pthread_attr_init (&attr);

  你也可以调用pthread_attr-destory()函数来清除这个属性结构,不过很少人会这么使用(如果你需要编写POSIX兼容代码,可能需要使用这个函数)。

标志线程属性(flag thread attribute)

  pthread_attr_setdetachstate()、pthread_attr_setinheritsched()和pthread_attr_setscope()这三个函数确定了线程是“分离的”还是“可联接的”、线程是否继承了创建线程的调度属性或使用通过pthread_attr_setschedparam()和pthread_attr_setschedpolicy()函数所设定的调度属性、以及线程是否有“全局”或“进程”的作用域。

  要创建一个“可联接”(也就是说其他线程可以通过pthread_join()函数与该线程的终端同步)的线程,要使用的函数如下:

(default)
pthread_attr_setdetachstate (&attr, PTHREAD_CREATE_JOINABLE);

  要创建一个不能联接的线程(或称分离的),使用的函数如下:

pthread_attr_setdetachstate (&attr, PTHREAD_CREATE_DETACHED);

  如果想要线程继承其创建线程的调度属性(就是指其与创建线程有相同的调度属性与优先级),使用的函数如下:

(default)
pthread_attr_setinheritsched (&attr, PTHREAD_INHERIT_SCHED);

  如果要创建的线程要使用属性结构(通过函数pthread_attr_setschedparam()与pthread_attr_setschedpolicy()设置)中的调度属性,要使用的函数如下:

pthread_attr_setinheritsched (&attr, PTHREAD_EXPLICIT_SCHED);

  可能你会注意到一直没有调用pthread_attr_setscope()函数。原因就是,本系统只支持“系统”作用域并且在初始化属性的时候是默认的。(“系统”作用域就是说在系统中的全部线程都在互相竞争CPU;“进程”作用域就是指线程只与同进程中的其他线程竞争CPU,系统内核来调度进程。)

  如果你执意要使用这个函数,你只能按照如下方式使用:

(default)
pthread_attr_setscope (&attr, PTHREAD_SCOPE_SYSTEM);
线程堆栈属性(stack thread attributes)

  线程属性的堆栈参数原型如下:

int
pthread_attr_setguardsize (pthread_attr_t *attr, size_t gsize);

int
pthread_attr_setstackaddr (pthread_attr_t *attr, void *addr);

int
pthread_attr_setstacksize (pthread_attr_t *attr, size_t ssize);

int
pthread_attr_setstacklazy (pthread_attr_t *attr, int lazystack);

  这些函数都使用属性结构作为函数的第一个参数,第二个参数为下面的参数之一:

gsize  守护区(guard area)的大小;

addr  堆栈的地址;

ssize  堆栈的大小;

lazystack  表示堆栈是应该预先还是有请求才从物理内存中分配。

  守护区是紧跟着堆栈后面的一块内存区,线程不会在这个区域写入。如果发生了写入(也就是说堆栈可能开始溢出了),线程就会收到一个SIGSEGV信号。如果守护区的大小为0,就意味着没有守护区。也就是没有堆栈溢出检查。如果守护区大小不是零,那么它最小是系统默认的守护区大小(系统默认大小可以通过使用常量_SC_PAGESIZE调用sysconf()函数得到)。需要注意的是守护区的最小也与一“页”的大小相同。另外也要注意,守护页实际上没有占用物理内存,它只是使用了虚拟地址。

  addr是堆栈的地址。你也可以将其设置为NULL,这样的话,系统就会为你的线程分配与释放堆栈了。指定堆栈的好处就是你可以对堆栈进行深度分析。分析的过程是这样的:分配堆栈区、在堆栈中灌入“签名”(例如把字符串“STACK”在其中反复重复),之后开始线程的运行,当线程运行结束后,你就可以查看堆栈区并可以查看线程抹掉了多少个你在堆栈区中的签名,之后你就可以知道在这次运行中堆栈使用的最大深度了。

  ssize参数确定了堆栈的大小。如果你在addr参数中指定了堆栈地址,ssize就是那个数据区域的大小。如果你没有在addr参数中指定地址而是使用NULL,ssize参数就告诉系统这个堆栈要分配多大的内存。如果你把0设定为参数ssize的值,系统就会为你分配默认堆栈大小的内存。最好不要在指定了addr之后再使用0为ssize的值。你可能要说的是“这里有一个指向对象的指针,这个对象是默认大小的”,问题是在目标大小和传递的值之间是没有任何绑定的。

  另外,当一个堆栈是通过addr参数而产生的,系统不会自动为其创建堆栈溢出保护,也就是说没有保护区。不过你可以通过mmap()与mprotect()函数对其设定。

  lazystack参数用来设定物理内存是按需分配(PTHREAD_STACK_LAZY)还是提前分配(PTHREAD_STACK_NOTLAZY)。按需分配的优点就是线程不在必要的时候不会使用更多的物理内存。缺点就是在小内存的环境中,有时当线程需要额外的堆栈而这时没有可用内存时,线程会莫名其妙的死掉。如果你使用PTHREAD_STACK_NOTLAZY,你最好自己设定实际的堆栈大小而不要使用系统分配的默认大小,因为系统默认大小有时是很大的。

线程调度属性(scheduling thread attributes)

  如果你在调用pthread_attr_setinheritsched()函数时使用了PTHREAD_EXPLICIT_SCHED常量,你就需要设定你要创建的线程的调度算法与优先级。

  这是通过如下两个函数实现的:

int
pthread_attr_setschedparam (pthread_attr_t *attr,
                            const struct sched_param *param);

int
pthread_attr_setschedpolicy (pthread_attr_t *attr,
                             int policy);

  调度规则很简单,是SCHED_FIFO, SCHED_RR或SCHED_OTHER之一。(现在SCHED_OTHER已经映射到SCHED_RR上了)

  param是包含了一个相关的参数sched_priority的数据结构。可以直接将需要的优先级赋值给这个参数。

  有一个需要注意的bug就是。当指定线程为PTHREAD_EXPLICIT_SCHED之后,只对调度策略进行了设置的话。当对属性结构初始化之后,param.sched_priority的值为0。这样就是与空闲进程的优先级一样了,也就是说你新建的线程将与空闲进程竞争CPU。这一点要特别注意。

 
一些例子

 
  在下面的例子里面,已经假设需要的引用文件(比如<pthread.h>与<sched.h>)已经被引用,并且要创建的线程通过new_thread()创建的并且已经正确地定义。

  最常用的创建线程方法就是使用默认值:

pthread_create (NULL, NULL, new_thread, NULL);
  在这个例子里面,我们使用默认值创建新的线程,并使用NULL作为其唯一的参数。

  通常你可以传递任何东西到你的新线程。下面的例子里面,我们传递的是数字123:

pthread_create (NULL, NULL, new_thread, (void *) 123);

  下面是一个更复杂的例子,用来创建一个非可联接线程,并使用环形调度策略、优先级为15:

pthread_attr_t attr;

// initialize the attribute structure
pthread_attr_init (&attr);

// set the detach state to "detached"
pthread_attr_setdetachstate (&attr, PTHREAD_CREATE_DETACHED);

// override the default of INHERIT_SCHED
pthread_attr_setinheritsched (&attr, PTHREAD_EXPLICIT_SCHED);
pthread_attr_setschedpolicy (&attr, SCHED_RR);
attr.param.sched_priority = 15;

// finally, create the thread
pthread_create (NULL, &attr, new_thread, NULL);

  如果想要看看多线程程序是怎样运行的,可以在命令行输入pidin命令。假设我们的程序叫做spud。如果我们在spud创建一个线程之前运行一次pidin,在spud创建多个线程之后运行一次pidin,下面就是我们可能看到的输出:

# pidin
pid    tid name               prio STATE       Blocked
 12301   1 spud                10r READY

# pidin
pid    tid name               prio STATE       Blocked
 12301   1 spud                10r READY
 12301   2 spud                10r READY
 12301   3 spud                10r READY

  在这里可以看到,进程spud(进程ID是12301)有三个线程(在tid列中)。这三个线程的运行优先级为10,并且其调度算法为环形(通过10之后的字母r表示)。这三个线程都处于就绪状态,也就是说它们都可以使用CPU但是还没有在CPU上运行(另外一个较高优先级的线程可能正在运行)。

  现在我们已经知道如何创建线程,下面看看我们应该在哪里以及如何使用它们。

何处使用线程最好

  在两种情况下使用线程是合适的。

  线程就像C++中的操作符重载,在有些时候对每个操作符重载来完成有趣的事可能看可能是个好主意,不过这可能会让代码难于理解。对于多线程也是类似的,你可以创建一堆线程,不过复杂性的增加会让你的代码难于理解并且也会难于维护。另一方面,明智的使用多线程会让你的程序非常简洁的实现预定功能。

  在需要并行操作的时候使用线程是不错的,比如同时进行数学处理(图像、数字信号处理等等)。另外当你想要你的程序完成多个独立功能的时候也是合适的,比如分享数据、像网络服务器那样同时服务多个用户。我们在下面详细说明这两种情况。

用于数学计算的多线程

  假设我们有一个完成光线跟踪的图形程序。屏幕上的每一个扫描线依赖于主数据库(主数据库则描述着正在生成的实际图像)。这里的关键是:扫描线之间都是互相独立的。这立刻就让这个问题成为一个可以使用多线程来解决的问题。

  下面是使用单线程的程序版本:

int
main (int argc, char **argv)
{
    int x1;

    …    // perform initializations

    for (x1 = 0; x1 < num_x_lines; x1++) {
        do_one_line (x1);
    }

    …    // display results
}

  我们可以看到通过x1的不停循环来逐个计算全部的扫描线。

  在一个对称多处理器系统中,这个程序只能使用一个CPU。原因就是我们没有让操作系统做任何并行操作。操作系统也没有智能到可以看到程序说:“等会,我有4个CPU,看来这个程序有独立的执行流程,我来在4个CPU上面运行它吧!”

  所以,只有系统设计者才能告诉操作系统哪些部分可以并行运行。最简单的办法就是:

int
main (int argc, char **argv)
{
    int x1;

    …    // perform initializations

    for (x1 = 0; x1 < num_x_lines; x1++) {
        pthread_create (NULL, NULL, do_one_line, (void *) x1);
    }

    …    // display results
}

  这个简单方法有很多问题。首先也是最重要的就是do_one_line()函数只能被修改为使用void *而不是int来作为其参数。这可以通过原型化来简单的修补。

  第二个问题就有些棘手。假设你要计算的图片的屏幕分辨率为1280X1024,我们就得创建1280个线程!这对于操作系统来说没有任何问题——操作系统对每个进程中的线程数目的限制为32767。不过每个线程必须要有一个独立并且唯一的堆栈。如果你的堆栈的大小是合理的(比如为8KB),那么你就要有1280X8KB(10M)的堆栈。这样做有必要吗?在你的对称多处理器系统中只有4个CPU。这就意味着这1280个线程中的4个才能在同一时刻运行,而另外的1276个线程就需要等待CPU。(在实际中,堆栈需要的空间只是在需要的时候才被分配,不过仍然是浪费的,因为这里还会有其他的开销。)

  解决这个问题的一个较好的处理方式就是将这个问题分为4片(每个针对一个CPU),并为每一片开启一个线程:

int num_lines_per_cpu;
int num_cpus;

int
main (int argc, char **argv)
{
    int cpu;

    …    // perform initializations

    // get the number of CPUs
    num_cpus = _syspage_ptr -> num_cpu;
    num_lines_per_cpu = num_x_lines / num_cpus;
    for (cpu = 0; cpu < num_cpus; cpu++) {
        pthread_create (NULL, NULL,
                        do_one_batch, (void *) cpu);
    }

    …    // display results
}

void *
do_one_batch (void *c)
{
    int cpu = (int) c;
    int x1;

    for (x1 = 0; x1 < num_lines_per_cpu; x1++) {
        do_line_line (x1 + cpu * num_lines_per_cpu);
    }
}

  在这里我们只用了num_cpus个线程。每个线程会在一个CPU上面运行。并且由于我们只使用了少量的线程,就不需要多余的堆栈来浪费内存。你可以留意我们是如何通过系统页面的全局变量_syspage_ptr来获取CPU个数的。

为对称多处理器或单处理器编程

  这段代码最好的部分就是即使在单处理器系统中也能正常运行——因为你只会创建一个线程来做所有的工作。让程序在多处理器系统上能够运行快些的灵活性已经足以弥补附加的一个堆栈的开销了。

线程终止的同步

  前面我们曾经提到过最初展示的简单代码有诸多问题。它的另外一个问题就是main()函数启动了一堆线程并接着显示结果。那么函数是如何才能知道安全显示结果的时间呢?

  让main()函数来处理这个竞争会让我们失去实时操作系统的意义:

int
main (int argc, char **argv)
{
    …

    // start threads as before

    while (num_lines_completed < num_x_lines) {
        sleep (1);
    }
}

  永远也不要写这样的代码!对于这个问题有两个简洁的解决方案:pthread_join()与pthread_barrier_wait()。

联接(Joining)

  最简单的同步方法就是在这些线程结束的时候联接它们。联接实际上就意味着等待结束。

  联接的实现是一个线程等待另外一个线程的结束。等待线程调用pthread_join()函数:

#include <pthread.h>

int
pthread_join (pthread_t thread, void **value_ptr);

  使用pthread_join()函数时,你把要联接的线程的ID传递给它,另外的一个可选参数就是value_ptr,这个参数可以用来存储被联接线程的结束返回值。(如果对该值不感兴趣可以使用NULL。)

  线程ID来自于pthread_create()函数。在前面的例子中,我们使用NULL来忽略了第一个参数。下面是我们修正后的代码:

int num_lines_per_cpu, num_cpus;

int main (int argc, char **argv)
{
    int cpu;
    pthread_t *thread_ids;

    …    // perform initializations
    thread_ids = malloc (sizeof (pthread_t) * num_cpus);

    num_lines_per_cpu = num_x_lines / num_cpus;
    for (cpu = 0; cpu < num_cpus; cpu++) {
        pthread_create (&thread_ids [cpu], NULL,
                        do_one_batch, (void *) cpu);
    }

    // synchronize to termination of all threads
    for (cpu = 0; cpu < num_cpus; cpu++) {
        pthread_join (thread_ids [cpu], NULL);
    }

    …    // display results
}

  这次我们传递给pthread_create()的第一个参数就是指向pthread_t的指针。这里就会保存新建线程的线程ID。在第一个for循环结束之后,就会有num_cpus个新线程运行,再加上原有的运行main()函数的线程。我们不太关心消耗全部CPU的main()线程,现在它要花时间等待了。

  这个等待是通过使用pthread_join()函数来顺序联接各个线程实现的。首先,我们等待thread_ids[0]线程结束。当它结束后,pthread_join()就会解除阻塞。在下一个for循环,就会等待thread_ids[1]线程结束,如此往复直到全部的num_cpus的线程都结束为止。

  这时的一个问题就是“如果线程以相反的次序结束该怎么办?”。假设有4个CPU,在最后一个CPU(CPU3)上面的线程先结束了,之后在第二个CPU上的线程接着结束了,该怎么办?不过,这种体制的优点就是坏事不会发生。

  最先发生的事就是pthread_join()函数会阻塞于thread_ids[0]线程。这时,thread_ids[3]线程结束了。这对于main()线程没有任何冲击,因为这个线程在等待第一个线程结束。之后第二个线程结束了,也没有任何影响。直到最终thread_ids[0]线程结束了,pthread_join()解除阻塞,之后就紧接着到了for循环的下一个循环了。在第二个循环中,pthread_join()对thread_ids[1]线程执行操作,不过由于这个线程已经结束,将不会有任何阻塞,pthread_join()会立即返回。就这样,我们的for循环会逐一处理其他线程,之后退出。之后,我们就知道我们已经同步了所有的计算线程,可以显示结果了。

 

Related posts:

  1. 多线程中壁垒(barrier)的使用
  2. 在单CPU上使用多线程
  3. 线程池(Pools of threads)
  4. 用于线程同步的条件变量(Condition Variables)
  5. 进程的启动

  1. 本文目前尚无任何评论.
  1. 本文目前尚无任何 trackbacks 和 pingbacks.