在多进程并发模型中,每一个客户端连接就需要fork 一个进程,fork 代价是昂贵的,fork 要把父进程的内存映像拷贝到子进程,并在子进程中复制所有描述字,如此等等,尽管当今的实现使用称为写时拷贝的技术,用以避免在子进程切实需要自己的拷贝之前把父进程的数据空间拷贝到了子进程。然而即便有这样的优化措施,fork 的代价仍然是昂贵的。
另外fork 返回之后父子进程之间信息的传递需要进程间通信(IPC)机制。
线程有助于解决这两个问题,线程也被成为轻权进程,线程的创建可能比进程的创建快10~100 倍,同一进程内的所有线程共享相同的全局内存,这使得线程之间易于共享信息,但这种共享也伴随着同步问题(锁机制也会带来不小的消耗)。
在服务端的线程模型实现方式一般有三种:
服务端分为主线程和工作线程,主线程负责 accept 连接,监听新客户的连接请求,并与之建立连接,工作线程则负责处理服务端与客户端的数据交互。因此,即使在工作线程阻塞的情况下,也只是阻塞在线程范围内,不会影响到主线程接受和处理新客户的连接请求。回忆前面的多进程模型,父进程负责监听建立连接,子进程负责数据交互,这里手法是相同的。代码如下
服务器端:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define PORT 6666
void client_handler(int cli_sockfd)
{
char buf[200];
int n;
n = read(cli_sockfd, buf, 200);
buf[n] = '\0';
printf("recv data is :%s\n", buf);
printf("input your words:");
scanf("%s", buf);
write(cli_sockfd, buf, strlen(buf));
close(cli_sockfd);//工作线程运行结束后,可以关闭连接套接字了
}
/*线程回调函数*/
void* thread_callback(void *arg)
{
int cli_sockfd = *(int *)arg;
free(arg);//释放开辟内存,我们只需要知道套接字描述符
pthread_detach(cli_sockfd);//将其变成detached状态,这样该工作线程运行结束后,相关资源都会被释放
client_handler(cli_sockfd);
}
int main(int argc,char **argv)
{
pthread_t tid;
int ser_sockfd, *cliptr;
int err;
int addlen;
struct sockaddr_in ser_addr;
struct sockaddr_in cli_addr;
ser_sockfd = socket(AF_INET,SOCK_STREAM,0); //创建套接字
if(ser_sockfd == -1)
{
printf("socket error:%s\n",strerror(errno));
return -1;
}
bzero(&ser_addr,sizeof(ser_addr));
ser_addr.sin_family = AF_INET;
ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);
ser_addr.sin_port = htons(PORT);
//将网际套接口地址结构捆绑到该套接口
err = bind(ser_sockfd,(struct sockaddr *)&ser_addr,sizeof(ser_addr));
if(err == -1)
{
printf("bind error:%s\n",strerror(errno));
return -1;
}
//将套接口转换为一个监听套接口,监听等待来自客户端的连接请求
err = listen(ser_sockfd,5);
if(err == -1)
{
printf("listen error\n");
return -1;
}
printf("listen the port:\n");
for(;;)
{
/*主线程*/
cliptr = (int *)malloc(sizeof(int));
addlen = sizeof(struct sockaddr);
*cliptr = accept(ser_sockfd,(struct sockaddr *)&cli_addr,&addlen);
pthread_create(&tid, NULL, thread_callback, (void *)cliptr);//创建工作线程
}
return 0;
}
客户端代码:
#include
#include
#include
#include
#include
#include
#include
#define PORT 6666
int main(int argc,char **argv)
{
int sockfd;
int err,n;
struct sockaddr_in addr_ser;
char sendline[200],recvline[200];
sockfd = socket(AF_INET,SOCK_STREAM,0); //创建套接字
if(sockfd == -1)
{
printf("socket error\n");
return -1;
}
bzero(&addr_ser,sizeof(addr_ser));
/*用通配地址和指定端口号装填一个网际接口地址结构*/
addr_ser.sin_family = AF_INET;
addr_ser.sin_addr.s_addr = htonl(INADDR_ANY);
addr_ser.sin_port = htons(PORT);
//TCP:客户(sockfd)向服务器(套接口地址结构)发起连接,主动请求
//服务器的IP地址和端口号有参数addr_ser指定
err = connect(sockfd,(struct sockaddr *)&addr_ser,sizeof(addr_ser));
if(err == -1)
{
printf("connect error\n");
return -1;
}
printf("connect with server...\n");
//数据传输
printf("Input your words:");
scanf("%s",sendline);
send(sockfd,sendline,strlen(sendline),0);
printf("waiting for server...\n");
n = recv(sockfd,recvline,100,0);
recvline[n] = '\0';
printf("recv data is:%s\n",recvline);
return 0;
}
其运行结果和前面的多进程并发模型一样,不同的是多线程在资源开销方面以及效率方面要优于多进程。
简洁说来,多线程并发模型(不涉及线程池)核心代码如下:
void client_handler(int cli_sockfd)
{
read(cli_sockfd, buf, ……);
doit(buf);
write(cli_sockfd, buf, ……);
close(cli_sockfd);
}
/*线程回调函数*/
void* thread_callback(void *arg)
{
int cli_sockfd = *(int *)arg;
free(arg);
pthread_detach(cli_sockfd);
client_handler(cli_sockfd);
}
//
bind(ser_sockfd);
listen(ser_sockfd);
for(;;)
{
cliptr = (int *)malloc(sizeof(int));
*cliptr = accpet(ser_sockfd, ……);
pthread_create(……, thread_callback, (void *)cliptr);
}
多线程并发模型与多进程并发模型在实现思想上有相似之处,但在细节之处却有很大的差异,这源于多线程自身的特点。同一进程下的线程共享进程下的资源,当访问同一变量时就需要考虑同步的问题,尤其是对于已连接套接字,不能采用下面这种方式:
void client_handler(int cli_sockfd)
{
read(cli_sockfd, buf, ……);
doit(buf);
write(cli_sockfd, buf, ……);
close(cli_sockfd);
}
/*线程回调函数*/
void* thread_callback(void *arg)
{
int cli_sockfd = *(int *)arg;//解引用取值
pthread_detach(cli_sockfd);
client_handler(cli_sockfd);
}
//
bind(ser_sockfd);
listen(ser_sockfd);
for(;;)
{
cli_sockfd = accpet(ser_sockfd, ……);
pthread_create(……, thread_callback, &cliptr);//套接字地址传递
}
上面创建线程最后一个参数的传递方式存在很大的弊端,事实上指针传递本身并没有错,这里传递过去的都是同一个地址,然后再回调函数中解引用获得这个地址存放的已连接套接字,但鉴于多线程的特点,我们必须考虑这样一种情况:一个客户与服务器建立连接生成一个 cli_sockfd(假设为5),在pthread_create 函数之前,也就是thread_callback 函数调用之前,恰好另一个客户与服务器建立连接并生成一个新的 cli_sockfd(假设为6),然后前面那个线程回调函数运行,通过地址(同一个地址)获得这个cli_sockfd 值,此时获得将是最终的这个cli_sockfd(也就是6),而不是先前的那个cli_sockfd(值为5),这样势必会造成连接错误。所以在指针传递连接套接字时,应该额外开辟堆内存存放,这样每次传递的不是同一个地址,使得每个线程都有各自的已连接套接字的拷贝,也就能避免出现上面的连接错误问题。
不同于多进程并发模型,在这里,主线程不能关闭已连接套接字,同一进程下的所有线程共享全部描述字,要是主线程关闭了已连接描述字,它就会终止相应的连接,工作线程也会终止出错。工作线程通信结束之后需要关闭已连接套接字,创建新线程并不会影响描述字的引用计数,都是共享同一个,线程结束不同于多进程,不会随着子进程的运行结束,自行销掉描述字。
多线程并发相比多进程并发用轻权重的线程的频繁创建来替代低效的进程创建,在一定程度上提高了程序性能,另外如果引用线程池,可以避免频繁的创建、销毁线程,更能大大提升程序性能,但是线程模型也具备先天缺陷,多线程稳定性较差,一个线程的崩溃有时会导致整个程序崩溃。进程下的所有线程共享内存资源,在临界资源的访问上,必须考虑同步的问题,处于解决这个同步问题目的而引入的锁机制在加大程序复杂性的同时,也会严重降低程序的性能,甚至在最终的性能还比不上多进程并发模型。
后续再来看看基于线程池的多线程并发模型。