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
- 参数
readfds
,writefds
,exceptfds
为描述符集合 - 参数
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
函数相似的还有pselect
,poll
以及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
创建一个新线程,在新线程上执行函数f
,f
的参数为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
的访问,slots
与items
分别同步可用的槽及可用的产品,insert
及remove
操作中的PV操作,保证了访问缓冲区时不会出现问题。值得一提的是,由于缓冲区时有限的,因此insert
操作在缓冲区满的时候会阻塞,即挂起当前线程,remove
操作在缓冲区空的时候会阻塞。
预线程化服务器
通过上述生产者-消费者模型,我们可以构造一个预线程化的服务器,大致流程如下:
- 主程序初始化N个线程、生产者消费者模型
sbuf
,并开始监听客户的连接- 主程序是生产者
- 对等的N个线程是消费者
- 消费者(线程)任务是从
sbuf
中取出连接描述符connfd
并处理
- 主程序不断将客户端建立的连接
connfd
(产品)加入到sbuf
中 - 每个线程通过上述生产者消费者模型同步地处理客户的连接
如下为代码(部分):
#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时输出的结果没有问题),因此多线程、进程之间共享数据的时候要非常小心地进行同步。与并发相关的还有pselect
、poll
与epoll
、线程安全函数、竞争、饥饿、死锁、读者写者问题等,感兴趣的可以参考Linux手册或其他文献博客。