1.程序、进程和线程
二进制程序(binaries)是指保存在存储介质上的程序,以给定操作系统和计算机体系结构可访问的格式编译生成,可以运行但尚未开始。
进程(processes)是操作系统对运行的二进制程序的抽象,包括:加载的二进制程序、虚拟内存、内核资源如打开的文件、关联的用户等。
线程(threads)是进程内的执行单元,具体包括:虚拟处理器、堆栈、程序状态。换句话说,进程是正在运行在二进制程序,线程是操作系统调度器可以调度的最小执行单元。
一个进程包含一个或多个线程。如果一个进程只包含一个线程,则该进程只有一个执行单元,每次只有一个操作在运行。我们称这种进程为“单线程”,它们是经典的UNIX进程。如果一个进程包含多个线程,每个会有多个操作在同时执行。我们称这种进程为“多线程”。
2.线程的优势
前面我们写了一个多进程的并发服务器程序,而多线程并发服务器相当于对多进程并发服务器的升级版。为什么这么说呢?因为相比于多进程,多线程具有很大的优势。
线程的一大性能优势在于同一个进程内的线程之间的上下文切换代价很低(进程内切换)。在任何系统上,进程内切换的代价低于进程间切换,前者通常是后者的一小部分。在非 Linux系统上,这种代价差别非常明显,进程间通信代价非常高。因此,在很多系统上,称线程为“轻量级进程”。
在Linux 中,进程间切换代价并不高,而进程内切换的成本接近于0:接近进入和退出内核的代价。进程的代价不高,但是线程的代价更低。
计算机体系结构对进程切换有影响,而线程不存在这个问题,因为进程切换涉及把一个虚拟地址空间切换到另一个虚拟地址空间。举个例子,在x86系统上,转换后备缓冲器(translation lookaside buffer, TLB),即用于把虚拟内存地址映射到物理内存地址的缓存,当切换到虚拟地址空间时,必须都清空。在某些负载场景下,TLB丢失对系统性能有极大损伤。在极端情况下,在某些ARM机器上,必须把整个CPU 缓存都清空!对于线程而言,不存在这些代价,因为线程到线程之间的切换并不会交换虚拟地址空间。
3.多线程并发服务器
多线程并发服务器的逻辑和多进程并发服务器的逻辑类似。后者是每当有一个新的客户端连接就创建一个新的进程去处理这个连接,前者是每当有一个新的客户端就创建一个线程去处理这个连接。
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <error.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#define PORT 8080
void *client_thread(void *arg);
int main()
{
int listenfd, connfd;
struct sockaddr_in servaddr;
pthread_t thread_id;
struct sockaddr_in cliaddr;//存储客户端的信息
socklen_t cliaddrlen = sizeof(cliaddr);
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(listenfd == -1)
{
perror("socket error:");
exit(1);
}
memset(&servaddr, 0, sizeof(servaddr));//使用 sockaddr_in 的时候要把 sin_zero 全部设成零值(使用 bzero()或 memset()函数)。
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(PORT);//将一个无符号短整型数值转换为网络字节序,即大端模式(big-endian)
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//将主机的无符号长整形数转换成网络字节顺序。
if(bind(listenfd, (struct sockaddr *)&servaddr, sizeof(struct sockaddr)) == -1)
{
perror("bind error:");
exit(1);
}
if(listen(listenfd, 10) == -1)
{
perror("listen error:");
exit(1);
}
while(1)
{
connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &cliaddrlen);
if(connfd == -1)
{
perror("accpet error:");
exit(1);
}else
{
printf("accept a new client: %s:%d,fd=%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port,connfd);
pthread_create(&thread_id, NULL, (void *)client_thread, (void *)connfd); //创建线程
pthread_detach(thread_id); // 线程分离,结束时自动回收资源
}
}
close(listenfd);
return 0;
}
void *client_thread(void *arg)
{
char buff[1024];
int n;//存储接收数据的长度
int connfd = (int)arg;
while(1)
{
n = read(connfd, buff, 1024);
if (n == -1)
{
perror("read error:");
close(connfd);
return;
}
else if (n == 0)
{
fprintf(stderr,"client close,fd=%d\n",connfd);
close(connfd);
return;
}
else
{
printf("read message is: %s,fd=%d\n",buff,connfd);
write(connfd, buff, n);
}
}
}
4.多线程的代价
虽然多线程有很多优势,但也不是毫无代价。事实上,有些最可怕的bug 就是由多线程引起的。设计、编写、理解,以及最重要的——调试多线程程序,这些复杂度都远远高于单个线程的进程。
对线程恐惧的原因还在于:多个虚拟的处理器,但是只有一个虚拟化内存实例。换句话说,多线程的进程有多个事件在同时运行(并发性),而这些事件共享同一块内存。自然而然地,同一个进程的线程会共享资源——也就是说,需要读或写同一份数据。因此,理解程序如何工作就从理解简单的序列化执行指令转变成对多线程独立运行的理解,时间和顺序不可预测,但结果肯定是正确的。如果线程同步失败,会造成输出脏数据,运行出错以及程序崩溃。由于理解和调试多线程代码非常困难,因此线程模型和同步策略必须从一开始就是系统设计的一部分。