Linux 并发编程简介-select,pthread与semaphore

20 篇文章 0 订阅
4 篇文章 0 订阅

select函数 - IO多路复用

select函数用来监控多个文件描述符上的读/写等事件,下为select相关的宏及函数:

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
                                    // 返回准备好的描述符总数,若错误,返回-1,并设置errno

void FD_CLR(int fd, fd_set *set);   // 从set中移除fd
int  FD_ISSET(int fd, fd_set *set); // 判断set是否包含fd
void FD_SET(int fd, fd_set *set);   // 将fd加入到set中
void FD_ZERO(fd_set *set);          // 初始化set
  • 参数readfdswritefdsexceptfds为描述符集合
  • 参数nfds表示为描述符集合中的最大值加一,即maxfd + 1
  • 参数timeout等待时间,为空表示挂起当前进程直到有一个或多个描述符准备好

select函数挂起当前进程并在下列三种情况后返回:

  • 超时timeout
  • 三个描述符集合中一个或多个描述符准备好
  • 被信号中断

返回值为三个集合中准备好的文件描述符的总数,准备好的描述符分别存放在三个参数中(即参数会被修改),在Linux系统上,timeout中会存放剩余的时间。
如下代码展示了select的使用,假设listenfd为服务器的监听套接字描述符,ready_set代替read_set作为参数传递给select

// int listenfd;
fd_set read_set, ready_set;

FD_ZERO(&read_set);
FD_SET(STDIN_FILENO, &read_set);
FD_SET(listenfd, &read_set);

ready_set = read_set;
int ret = select(listenfd + 1, &ready_set, NULL, NULL, NULL);
if (ret == -1) {
    // error occur
    exit(-1);
}

if (FD_ISSET(STDIN_FILENO, &ready_set)) {
    // stdin is ready, read data and do something
}
if (FD_ISSET(listenfd, &ready_set)) {
    // listenfd is ready, ready data and do something
}

select函数相似的还有pselectpoll以及epoll函数,详情可参考Linux手册

POSIX线程pthread简介

Posix线程pthread是C程序中处理线程的标准接口,使用gcc编译使用pthread的程序时需要加上参数-lpthread。Posix线程定义了多个函数用来创建、回收、结束线程等。值得一提的是,线程相关的函数执行成功时返回0,出错时返回相应的错误码errcode,并且可以用strerror(errcode)得到描述该错误的字符串。

一个进程默认包含一个主线程,通过pthread_create函数创建对等线程,每个线程可以通过pthread_self函数获得自己的线程ID。多个线程通过pthread_once函数初始化共享变量。线程的工作函数执行完后会隐式结束,而调用pthread_exit函数会显式地终止当前线程,值得一提的是主线程调用pthread_exit会等待所有对等线程结束,然后终止主线程及进程。一个线程可以通过调用pthread_cancel终止其他线程,或者调用pthread_join等待其他线程终止并回收其资源。为了避免内存泄漏,每个未分离的线程要么被其它线程显式地回收,要么调用pthread_detach函数分离,交由系统释放其资源。

创建线程

#include <pthread.h>
typedef void *(func)(void*);

int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
                        // 成功返回0,出错返回非零表示错误码

pthread_t pthread_self(void);
                        // 返回调用者的线程ID

pthread_create创建一个新线程,在新线程上执行函数ff的参数为arg。参数tid存放新创建线程的ID,attr用来设置线程的属性,一般为NULL。

终止线程

一个线程的终止有如下四种方式:

  • 线程的工作函数返回后隐式终止
  • 调用pthread_exit终止自己,主线程调用会等待其所有子线程终止
  • 线程调用了exit函数终止了进程及所有线程
  • 其它线程调用pthread_cancel函数终止当前线程
#include <pthread.h>

void pthread_exit(void *retval);
                        // 不返回,调用pthread_join的线程会获得retval的值

int pthread_cancel(pthread_t tid);
                        // 成功返回0,出错返回非零错误码

回收已终止线程的资源

线程通过调用pthread_join函数等待其他线程终止并回收其资源:

#include <pthread.h>

int pthread_join(pthread_t tid, void **retval);
                        // 成功返回0,出错返回非零错误码

该函数会一直阻塞,直到线程tid终止,tid的返回值retval指针赋值为该函数的第二个参数指向的值,之后回收其资源。值得一提的是,该函数只能等待一个指定的线程终止。

分离线程

如前所述,分离线程可以将线程的资源回收过程交由系统负责:

#include <pthread.h>

int pthread_detach(pthread_t tid);
                        // 成功返回0,出错返回非零错误码

线程一个通过pthread_detach(pthread_self())分离自己。分离线程在多线程程序中非常有用,将资源回收交由系统负责减轻了程序员的负担。

初始化线程的共享变量

如果我们想要只在第一个对等线程创建时初始某个共享变量,那么就可以使用pthread_once函数:

#include <pthread.h>

pthread_once_t once_control = PTHREAD_ONCE_INIT;

int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
                        // 返回0

once_control变量是一个值为PTHREAD_ONCE_INIT全局或静态变量。init_routine只会在第一次调用该函数时被执行,可以用来初始化一些多线程之间的共享变量。

同步线程 - 信号量

多线程共享变量时的同步问题相当棘手,与之相关的如信号量和PV操作、生产者消费者问题、读者写者问题等也是操作系统课程中必不可少的。考虑如下代码:

void *thread_work(void *vargp);
volatile long cnt = 0;

int main(int argc, char const *argv[]) {
    long n = 1000000;
    pthread_t tid1, tid2;

    pthread_create(&tid1, NULL, thread_work, (void *)&n);
    pthread_create(&tid2, NULL, thread_work, (void *)&n);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    if (cnt != 2 * n) 
        printf("BOOM! cnt=%ld\n", cnt);
    else 
        printf("OK cnt=%ld\n", cnt);
    return 0;
}

void *thread_work(void *vargp) {
    long i, n = *((long *)vargp);

    for (int i = 0; i < n; i++) 
        cnt++;
}

主线程创建两个对等线程,并在每个里面对共享变量cnt执行n=1000000次的自增操作,理论上来讲,对等线程结束后,cnt的值应当为2 * 1000000,然而其输出不仅不是而且每次都不一样:

eric@jeffrey:/Coding$: ./a.sh
BOOM! cnt=1445085
eric@jeffrey:/Coding$: ./a.sh
BOOM! cnt=1404746

问题在于子线程for循环中的自增操作cnt++,这个操作会首先将cnt的内容复制到寄存器中,交由cpu执行加一操作,之后将加一后的值重新写回cnt变量的内存区域中。显然,该过程并不是一个原子的过程,因此可能出现在线程A取出cnt内容还未写回内存时,线程B被调度并执行了自增操作,然后线程A被调度,将旧的cnt + 1写入了内存中,从而造成了cnt的值小于2000000的问题。解决该问题的方法是信号量。
信号量由Dijkstra提出,包括P和V两个操作:

  • P(s): 若s非0,则将s减一,并立即返回。若s为0,则挂起当前线程。V操作会重启该线程,重启后P操作将s减一,并返回
  • V(s): 将s加一,如果有多个线程调用P操作阻塞,则重启其中的某个线程
  • 这两个操作都是原子的
    Posix标准定义了几个操作信号量的函数:
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_wait(sem_t *sem);
int sem_post(sem_t *sem);
                            // 成功返回0,错误返回-1,并设置errno

上述函数中,sem_init函数用来初始化信号量,pshared参数为0表示只在线程间共享,sem_wait为P操作,sem_post为V操作。和Posix线程一样,使用信号量semaphore的程序需要在连接时加上-lpthread选项。采用信号量同步前述程序如下:

void *thread_work(void *vargp);

volatile long cnt = 0;
sem_t mutex;

int main(int argc, char const *argv[]) {
    long n = 1000000;
    pthread_t tid1, tid2;
    sem_init(&mutex, 0, 1);

    pthread_create(&tid1, NULL, thread_work, (void *)&n);
    pthread_create(&tid2, NULL, thread_work, (void *)&n);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    if (cnt != 2 * n) {
        printf("BOOM! cnt=%ld\n", cnt);
    } else {
        printf("OK cnt=%ld\n", cnt);
    }
    return 0;
}

void *thread_work(void *vargp) {
    long i, n = *((long *)vargp);
    for (int i = 0; i < n; i++) {
        sem_wait(&mutex);
        cnt++;
        sem_post(&mutex);
    }
}

通过在临界区前后加上PV操作,保证了cpu执行cnt++时只会有一个线程访问cnt

生产者消费者模型

信号量的一个经典例子是生产者-消费者问题:生产者和消费者共享一个有n个槽slot的缓冲区,生产者不断地生产产品放入slot中,消费者不断从slot中取出产品处理。
由于生产者放入与消费者取出产品都涉及到共享变量的更新,因此需要保证他们对缓冲区槽的访问时互斥的。如下代码构造了一个生产者-消费者模型:

struct sbuf_t {
    int *buf;
    int n;
    int front, rear;
    sem_t mutex, slots, items;     // mutex-同步buf,slots-同步空槽,items-同步非空槽

    // 初始化pv模型
    void init(int nn) {
        buf = (int *)calloc(nn, sizeof(int));
        n = nn;
        front = rear = 0;
        sem_init(&mutex, 0, 1);
        sem_init(&slots, 0, nn);
        sem_init(&items, 0, 0);
    }
    // 释放内存buf
    void destruct() {
        free(buf);
    }
    // 向产品槽中增加一个产品
    void insert(int item) {
        sem_wait(&slots);
        sem_wait(&mutex);
        buf[(++rear) % n] = item;
        sem_post(&mutex);
        sem_post(&items);
    }
    // 从槽中取出一个产品
    int remove() {
        int item;
        sem_wait(&items);
        sem_wait(&mutex);
        item = buf[(++front) % n];
        sem_post(&mutex);
        sem_post(&slots);
        return item;
    }
};

该结构中,mutex用来同步对缓冲区buf的访问,slotsitems分别同步可用的槽及可用的产品,insertremove操作中的PV操作,保证了访问缓冲区时不会出现问题。值得一提的是,由于缓冲区时有限的,因此insert操作在缓冲区满的时候会阻塞,即挂起当前线程,remove操作在缓冲区空的时候会阻塞。

预线程化服务器

通过上述生产者-消费者模型,我们可以构造一个预线程化的服务器,大致流程如下:

  1. 主程序初始化N个线程、生产者消费者模型sbuf,并开始监听客户的连接
    1. 主程序是生产者
    2. 对等的N个线程是消费者
    3. 消费者(线程)任务是从sbuf中取出连接描述符connfd并处理
  2. 主程序不断将客户端建立的连接connfd(产品)加入到sbuf
  3. 每个线程通过上述生产者消费者模型同步地处理客户的连接

如下为代码(部分):

#define NTHREADS 4
#define SUBFIZE 16

// 每个线程(消费者)的任务
void echo_cnt(int connfd);

// 线程的工作函数
void *thread_work(void *vargp);

// 生产者-消费者模型
sbuf_t sbuf;

int main(int argc, char const *argv[]) {
    int ret = 0;
    int i, listenfd, connfd;
    sockaddr_storage clientaddr;
    socklen_t clientlen;
    pthread_t tid;

    char port[] = "2333";
    listenfd = open_listenfd(port);

    // 初始化模型与线程
    sbuf.init(NTHREADS);
    for (int i = 0; i < NTHREADS; i++) 
        ret = pthread_create(&tid, NULL, thread_work, (void *)i);

    while (1) {
        clientlen = sizeof(sockaddr_storage);
        connfd = accept(listenfd, (sockaddr *)&clientaddr, &clientlen);
        // 将客户端连接加入模型中
        sbuf.insert(connfd);
    }
    return 0;
}

void *thread_work(void *vargp) {
    // 分离自身,由系统回收资源
    int ret = pthread_detach(pthread_self());
    while (1) {
        int connfd = sbuf.remove();
        echo_cnt(connfd);
        ret = close(connfd);
    }
}

可以看到,主程序作为生产者,不断地将客户端建立的连接加入sbuf的缓冲区槽中,每个线程作为消费者,不断地从槽中取出连接并执行处理。

总结

Linux下并发编程可以使用进程、线程、IO多路复用等,由于CPU的调度是不可预知的,而且并发问题往往不容易重现(如前述加法例子,当n为100、10000时输出的结果没有问题),因此多线程、进程之间共享数据的时候要非常小心地进行同步。与并发相关的还有pselectpollepoll、线程安全函数、竞争、饥饿、死锁、读者写者问题等,感兴趣的可以参考Linux手册或其他文献博客。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值