操作系统学习笔记3--并发

        多线程程序中的每个线程类似于一个独立的进程,但它们共享地址空间,可以访问相同的数据。线程都有程序计数器(PC),记录程序从哪里获取指令,也都有一组用于计算的寄存器,都有自己独立的栈。

线程的上下文切换:

        线程的上下文切换类似于进程的上下文切换。对于进程,我们需要将状态(通用寄存器、状态寄存器、程序计数器、用户栈和内核数据结构,包括页表、进程表、文件表等)保存至进程控制块(Process Control Block,PCB)。而线程呢,就需要将状态(通用寄存器、程序计数器和线程栈)保存至线程控制块(Thread Control Block,TCB)。与进程上下文切换相比,线程上下文切换的开销要小得多。线程上下文切换最主要的一个区别就是:地址空间保持不变(即不需要切换当前使用的页表,线程共享进程的内存空间)。

关键并发术语:

        临界区:访问共享资源的一段代码,资源通常是一个变量或者数据结构。

        竞态条件:多个执行线程大致同时进入临界区,它们都是试图更新共享数据结构时,导致了非期望结果。

        不确定性:程序由一个或多个竞态条件组成,程序输出因运行而异,具体决定于哪些线程在何时执行,这导致输出结果是不确定的。

        互斥执行:为避免这些问题,线程应该使用某种互斥原语,保证只有一个线程进入临界区,从而避免出现竞态,产生确定的输出。

1.线程的创建

        在POSIX中,线程创建很简单:

#include <pthread.h>
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void*), void* arg);

该函数有4个参数:thread是指向pthread_t结构类型的指针;attr用于指定该线程可能具有的属性;start_routine是一个函数指针,指明该线程在哪个函数中运行;arg是要传递给线程开始执行的函数的参数。

        调用pthread_join()函数可以等待线程的完成:

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

该函数有两个参数:thread是要等待的线程,第二参数是你希望得到的返回值。

2.锁(lock)

        POSIX库将锁(lock)称为互斥量(mutex),通过它提供线程间互斥进入临界区,保证临界区能够像单条原子指令一样执行。通过给临界区加锁,可以保证临界区内只有一个线程活跃。锁将原本由操作系统调度的混乱状态变得更为可控。

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

/* 具有零超时的 timedlock 将退化为 trylock */
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex, struct timespec *abs_timeout);

    pthread_mutex_destroy();

        所有的锁都必须正确的初始化,POSIX线程有两种方法来初始化锁 :

/* 1.使用 PTHREAD_MUTEX_INITIALIZER 初始化锁 */
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

/* 2.调用初始化函数动态初始化:第二参数是一组可选属性,传入NULL使用默认值 */
int initMutex = pthread_mutex_init(&mutex, NULL);
assert(initMutex == 0);

        无论哪种方式都有效,但我们通常使用动态初始化方法。请注意,当用完锁时,还应该相应地调用pthread_mutex_destroy()。

        对锁的评价主要从互斥性、公平性和性能三个方面评价。互斥性是其最基本的功能;公平性是指每个线程能有公平的机制抢到锁,不会有线程被饿死;性能主要是枷锁之后的时间开销。

        通常会用不同的锁保护不同的数据和结构,从而允许更多的线程进入临界区。

2.1锁的实现

2.1.1.测试并设置指令(原子交换)

        最简单的硬件支持是测试并设置指令(test-and-set instruction),也叫作原子交换(atomic exchange)。测试并设置指令的关键是原子地执行了这些代码。这一条指令完全可以实现一个简单的自旋锁(spin lock)。

int TestAndSet(int *old_ptr, int new)
{
    int old = *old_ptr;
    *old_ptr = new;
    return old;
}

测试并设置指令的锁的实现:

typedef struct loct_t
{
    int flag;
}lock_t;

void init(lock_t *lock)
{
    lock->flag = 0;
}

void lock(lock_t *lock)
{
    while (TestAndSet(&lock_flag, 1) == 1)
        ; //自旋
}

void unlock(lock_t *lock)
{
    lock_>flag = 0;
}

        对自旋锁的评价:自旋锁实现了互斥性的基本功能。但是在公平性没有任何保证,可能饿死。在性能方面,如果是单处理器,一个线程获得锁后被抢占,其他线程拿不到锁,在放弃CPU前会自旋一段时间,浪费一些CPU周期;如果是多处理器,自旋等待其他处理器上的锁并没有浪费很多CPU时间,性能还行。

2.1.2.比较并交换

        某些系统提供了另一个硬件原语--比较并交换指令。其伪代码如下:

int CompareAndSwap(int *ptr, int expected, int new)
{
    int actual = *ptr;
    if (actual == expected)
    {
        *ptr = new;
    }
    return actual;
}

void lock(lock_t *lock)
{
    while (CompareAndSwap(&lock->flag, 0, 1) == 1)
        ; //spin
}

        基于硬件的锁简单有效,但是单处理器中容易自旋,浪费CPU时间,尤其是多个线程竞争一个锁的情况。怎样减少自旋呢?

        第一种方法是要自旋时,就放弃CPU。操作系统提供原语yield(),线程可以调用它主动放弃CPU,让线程从运行(running)态变为就绪(ready)态,让其他线程运行。让出线程本质上取消调度(deschedules)了它自己。但在单CPU的多个线程竞争同一把锁时,虽然没有浪费N个CPU时间片,但是N次线程的上下文切换是实在的,浪费依然很大。极端情况也会出现线程饿死。

        第二种方法是要自旋时,就休眠,并且用队列保存等待锁的线程,以便在锁可用时获得锁,避免饿死。

3.条件变量(condition variable)

        锁并不是并发程序设计所需的唯一原语,在很多情况下,线程需要检查某一条件满足之后,才会继续运行。简单的方案是用一个共享变量,让程序自旋检查,直到条件满足。这种方案会浪费CPU时间,甚至出现错误。

        线程可以使用条件变量(condition variable),来等待一个条件变成真。条件变量是一个显式队列,当某些执行状态(即条件)不满足时,线程可以把自己加入队列,等待该条件。另外某个线程,当它改变了上述状态时,就可以唤醒一个或者多个等待线程(通过在该条件上发信号),让它们继续执行。条件变量的声明:pthread_cond_t c;条件变量有两种常见操作,如下:

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);

        要使用条件变量,必须另外有一个与此条件相关的锁。在调用上述任何一个函数时,应该持有这个锁。wait()函数调用线程进入休眠状态,同时释放锁,等待其他线程发出信号。但在唤醒时,wait()函数会重新获取该锁,再返回到调用者,以确保等待线程在开始获取锁到结束释放锁之间的任何时候都持有锁。这样复杂的步骤也是为了避免在线程陷入休眠时,产生一些竞态条件。

int done = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITLIALIZER;

void child()
{
    Pthread_mutex_lock(&m);
    done = 1;
    Pthread_cond_signal(&c);
    Pthread_mutex_unlock(&m);
}

void main()
{
    /* ... */

    Pthread_mutex_lock(&m);
    while(done = 0)
    {
        Pthread_cond_wait(&c, &m);
    }
    Pthread_mutex_unlock(&m);

    /* ... */
}

注意:等待线程使用while循环重新检查条件,而不是简单的if语句。

        调用wait()和signal()时都要持有锁。

        线程之间总是通过条件变量发送信号。切记不要用标记变量来同步。

3.1生产者消费者(有界缓冲区)问题

        因为有界缓冲区是共享资源,所以我们必须通过同步机制来访问它,以免产生竞态条件。

        生产者消费者问题的解决方案是使用两个条件变量来管理生产和消费,同时增加缓冲区大小,只有缓冲区满时,生产者才睡眠,缓冲区空时,消费者才睡眠。

4.信号量

        信号量是有一个整数值的对象,可以用两个函数来操作它。在POSIX标准中,是sem_wait()和sem_post()。因为信号量的初始值能够决定其行为,所以首先要初始化信号量,才能调用其他函数与之交互。

sem_t s;
sem_init(&s, 0, 1);    //第三个参数将其初始化为1


        首先,sem_wait()要么立刻返回(调用sem_wait()时,信号量的值大于等于1),要么会让调用线程挂起,直到之后的一个post操作。如果多个调用线程都调用sem_wait(),则都在队列中等待被唤醒。其次,sem_post()并没有等待某些条件满足。它直接增加信号量的值,如果有等待线程,唤醒其中一个。最后,当信号量的值为负数时,这个值就是等待线程的个数。虽然这个值通常不会暴露给信号量的使用者,但这个恒定的关系值得了解,可能有助于记住信号量的工作原理。

        可以使用信号量作为锁和条件变量。二值信号量就是锁,需要初始化为1。

构建多线程程序时的注意事项:

1.保持简洁。线程之间的锁和信号的代码应该尽可能简洁,复杂的线程交互容易产生缺陷。

2.让线程交互减到最少。尽量减少线程之间的交互,每次交互都应该想清楚,并用验证过的、正确的方法来实现。

3.初始化锁和条件变量。未初始化的代码有时工作正常,有时失败,会产生奇怪的结果。

4.检查返回值。否则会导致古怪而难以理解的行为。

5.注意传给线程的参数和返回值。如果传递在栈上分配的变量的引用,就是在犯错误。

6.每个线程都有自己的栈。类似于上一条,记住每一个线程都有自己的栈。因此,线程局部变量应该是线程私有的,其他线程不应该访问。线程之间共享数据,值要在堆(heap)或者其他全局可访问的位置。

5.基于事件的并发

        我们等待某事(即“事件”)发生;当它发生时,检查事件类型,然后做少量的相应工作(可能是I/O请求,或者调度其他事件准备后续处理)。

事件循环的伪代码:

while(1)
{
    events = getEvents();
    for (e : events)
    {
        processEvent(e);
    }
}

        接下来必须解决如何接收事件的问题,大多数系统提供了基本的API,即通过select()或poll()系统调用。I/O多路复用技术 select poll epoll。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值