多线程并发服务器
在多进程并发服务器的应用程序中,父进程accept
一个连接,fork
一个子进程,该子进程负责处理与该连接对端的客户之间的通信。
尽管多进程的编程模型中,各进程拥有独立的地址空间,减少了出错的概率,然而,fork
调用却存在一些问题:
fork
是昂贵的,fork
要把父进程的内存映像复制到子进程,并在子进程中复制所有描述符,这个操作是较重量级的。fork
返回之后父子进程之间信息的传递需要进程间通信(IPC)机制。
线程则可以解决上述两个问题。线程有时也称为轻量级的进程,线程的创建可能比进程的创建快10-100倍。同一个进程内所有线程共享相同的全局内存,这使得线程之间易于共享信息,但伴随这种简易性而来的是线程安全问题。
线程函数
1. pthread_create 函数
我们介绍的第一个线程的函数是pthread_create
,它的作用是创建一个新线程。它的定义如下:
1 |
|
这个函数的定义看起来很复杂,其实用起来很简单。
第一个参数是指向pthread_t
类型的指针。线程被创建时,这个指针指向的变量将被写入一个标识符(线程ID)我们用该标识符来引用新线程。
第二个参数用于设置线程的属性,一般不需要特殊的属性,所以只需要设置该参数为NULL。
最后两个参数,分别告诉新线程将要启动执行的函数和传递给该函数的参数。pthread_create
函数在成功调用时返回0,如果失败则返回失败码。
2. pthread_exit 函数
线程通过调用pthread_exit
函数终止执行。这个函数的作用是,终止调用它的线程并返回一个指向某个对象的指针。
1 |
|
3. pthread_join 函数
pthread_join
函数的作用是等待某个线程的结束。其第一个参数指定了需要等待的线程ID,第二个参数是一个二级指针,它指向另一个指针,而后者指向线程的返回值。
1 |
|
4. pthread_self 函数
每个线程都有一个在所属进程内标识自己的ID。线程ID由phtread_create
返回,而且我们已经看到pthread_join
也使用了线程ID来指定等待哪个线程。pthread_self
的作用是返回自身的线程ID。
1 |
|
5. pthread_detach 函数
一个线程或者是可汇合(joinable),或者是脱离的(detached)。当一个可汇合的线程终止时,它的线程ID和退出状态将留存到另一个线程对它调用pthread_join
的返回值中。脱离的线程终止时,所有相关资源都被释放,我们不能等待它们终止。如果一个线程需要知道另一个线程什么时候终止,那就最好保持第二个线程的可汇合状态。pthread_detach
函数把指定的线程转变为脱离的状态。
1 |
|
第一个线程例子
我们的第一个线程的例子如下:
1 |
|
首先,我们调用pthread_create
创建了一个新线程,在调用pthread_create
函数时,我们向其传递了一个函数指针thread_function
,即新线程的执行函数,以及传递给该执行函数的参数message
。
创建新线程后,主线程通过pthread_join
等待新线程的执行完毕。而新线程执行thread_function
函数,修改全局数据message
,然后退出线程并向主线程返回一个字符串。主线程等待新线程执行完毕后,获得新线程的返回值和修改后的全局数组。
编译运行上面的程序,得到以下输出:
Waiting for thread to finish..
thread_function is running. Argument was Hello pthread!
Thread joined, it returned Thank you for the CPU time.
Message is now Bye!
基于线程的并发服务器
下面展示了基于线程的并发服务器的代码。整体结构类似于基于进程的设计。主线程不断地等待连接请求,然后创建一个新线程处理该请求。
1 |
|
代码虽然较简单,但有几个地方值得我们重点关注一下。
第一个问题是我们在调用pthread_create
时,如何将已连接套接字描述符传递给新线程。最容易想到的方法如下:
1 | connfd = accept(listenfd, (struct sockaddr*)&client_address, &client_len); |
然后,在线程函数中引用这个指针变量,并将其赋值给一个局部变量。
1 | void *thread_function(void *arg) { |
然而,这个做法可能会带来线程安全的问题。如果赋值语句在下一个accept
之前完成,那么线程函数中的局部变量connfd
将得到正确值。如果赋值语句在下一个accept
之后才完成,那么线程函数中的局部变量connfd
就会得到下一次连接的描述符的值。这显然不是我们想要的结果。
为了避免这种情况的出现,每次调用accept
返回时,将返回的已连接套接字描述符存储在动态分配的内存中,这样无论线程函数中的赋值先于还是后于下一个accept
完成,都不会出现线程安全的问题。
另一个问题是在线程函数中避免存储器资源泄漏。既然我们不显示式回收线程,我们就必须分离每个线程,使得它们在终止时存储器资源能够被回收。另外,还有一点需要提醒的,在线程函数中必须将主线程分配的动态内存释放了。
最后一个问题是如何关闭套接字描述符的问题。在基于进程的服务器中,我们在父进程和子进程两个位置都关闭了已连接套接字描述符。但在基于线程的服务器中,我们只需要在线程函数中关闭已连接套接字描述符,而不需要在主线程中关闭。
在Linux系统中,每个文件或者套接字都有一个引用计数,引用计数在文件表项中维护,它是当前打开着的引用该文件或者套接字的描述符的个数。对于多进程服务器的情形,已连接套接字描述符在父进程和子进程间共享(也就是被复制),因此已连接套接字相关联的文件表项的访问计数值为2,故在父进程和子进程都需要执行close
操作。而对于多线程服务器的情形,由于线程间具有相同的地址空间,套接字描述符并不进程复制操作,即已连接套接字描述符的计数值为1,故只需要在创建的新线程中执行一次close
操作即可。
参考资料
- 深入理解计算机系统,第2版,机械工业出版社
- Linux程序设计(第4版),Neil Matthew等著,人民邮电出版社,2010年
- UNIX 网络编程卷1:套接字联网API(第三版), W.Richard Stevens 等著
- http://www.tuicool.com/articles/fiEfaa