一、TCP单对多服务
前面我对TCP进行了单对单的讲解,使用连接就回复"Hello, world"的例子说明简单tcp的连接传输,但一般来说,我们经常见到的服务器是属于那种单对多的,一台服务器可以为多个客户提供服务的,那这个怎么实现的呢?所以这里针对这个单对多进行一下赘述。
针对实现,我们知道前面的例子是服务端处理完单个客户端的连接请求就退出了服务,所以我们想要实现单个服务器对应多个客户请求,就可以循环调用accept函数,使得它呈现以下的流程: 然后我们实现一下回声服务的迭代服务
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF 1024
void errorHandling(char* message);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
char message[BUF];
int str_len, i = 0;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t clnt_addr_size;
if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(-1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
errorHandling("socket() error!");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr)) == -1)
errorHandling("bind() error!");
if (listen(serv_sock, 5) == -1)
errorHandling("listen() error!");
clnt_addr_size = sizeof(clnt_addr);
while (i < 5) {
clnt_sock = accept(serv_sock, (struct sockaddr*) &clnt_addr, &clnt_addr_size);
if (clnt_sock == -1)
errorHandling("accept() error!");
else
printf("第%d客户端 : %s 连接上了\n", ++i, inet_ntoa(clnt_addr.sin_addr));
while((str_len = read(clnt_sock, message, BUF)) != 0)
write(clnt_sock, message, str_len);
close(clnt_sock);
}
close(serv_sock);
return 0;
}
void errorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(-1);
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF 1024
void errorHandling(char* message);
int main(int argc, char* argv[]){
int sock, str_len;
char message[BUF];
struct sockaddr_in serv_addr;
if (argc != 3) {
printf("Usage: %s <IP> <port>\n", argv[0]);
exit(-1);
}
sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1)
errorHandling("socket() error!");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
serv_addr.sin_port = htons(atoi(argv[2]));
if (connect(sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr)) == -1)
errorHandling("connect() error!");
else
printf("Connected successfully......\n");
while(1) {
fputs("Me(q to quit): ", stdout);
fgets(message, BUF, stdin);
if (!strcmp(message, "q\n")||!strcmp(message, "Q\n"))
break;
write(sock, message, strlen(message));
str_len = read(sock, message, BUF - 1);
message[str_len] = 0;
printf("Back: %s", message);
}
close(sock);
return 0;
}
void errorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(-1);
}
然后我们可以打开多个终端来运行我们的echo客户端,可以看到的是,多个客户端都会出现以下情形:
jack@DESKTOP-SJO8SMG:/mnt/c/Users/samu$ ./echo_client 127.0.0.1 9999
Connected successfully......
Me(q to quit):
蛮有意思的是,服务端那边并不一定给你进行连接的显示,也就是,客户端使用connect函数是成功的,但服务端那边并没有打印连接上的服务端信息。整个流程走完的服务端是这样显示的:
jack@DESKTOP-SJO8SMG:/mnt/c/Users/samu$ ./echo_server 9999
第1客户端 : 127.0.0.1 连接上了
第2客户端 : 127.0.0.1 连接上了
第3客户端 : 172.25.106.148 连接上了
第4客户端 : 127.0.0.1 连接上了
第5客户端 : 127.0.0.1 连接上了
为什么呢?因为它阻塞了,服务端根据程序走向,卡在了我们回应其中一个客户端的while循环中了,也就是说,同一时间,服务端只对一个客户端进行服务。但这样不行啊,我们需要的是同一时间能有多个客户端都有响应服务才行。这怎么做到呢?这就涉及并发了。
二、真实的TCP单对多服务
关于并发,在windows中并发编程代表多线程,linux中的并发代表多进程,这是因为在windows中新开一个线程的花销比进程要小,而linux中则是反过来,所以就出现这种状况。然后,我们知道各种服务器基本用的都是linux系统,现在我们整的就是多进程服务端。
准备知识
对于进程,我们需要知道的就是它是运行中的程序,当它占用CPU在执行指令的时候就是处于执行状态,当它需要输入输出或者进行其他任务而暂停时,这个状态称为阻塞状态,还有就是进程所需资源都准备好了,就差CPU调用就是就绪状态,这个状态的进程处于一个调用队列中,一大堆进程都在这里等待CPU调用。
使用函数:
#include <unistd.h>
pid_t fork(void);
上面的函数专门用来创建子进程,所以在父进程中会返回进程ID,在子进程中则会返回0。父子进程共享同一份代码,但变量不共享。需要重视的是,资源的申请在c/c++中,往往需要自己回收销毁,而不能自动回收(虽然智能指针可以做到这点),所以在编写创建资源代码的同时就该考虑回收代码的编写。
子进程的使用也要注意,当子进程没有被正确销毁时,它就会成为僵尸进程卡着你的工作。为了防止产生僵尸进程,需要向创建子进程的父进程传递子进程的exit参数或return返回值,一般需要父进程主动获取子进程的结束状态值,实现这个功能的有以下两个函数:
#include <sys/wait.h>
pid_t wait(int* statloc);
statloc指针所指位置可以用来保存子进程终止时传递的返回值,但这些信息不是单一的,需要分离:
WIFEXITITED(statloc);
WEXITSTATUS(statloc);
调用wait函数,如果没有已终止的子进程,程序将会一直阻塞,这就是函数名wait的由来,你没结束,我等你。那如果不想阻塞呢?那就可以使用waitpid函数。
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* statloc, int options);
和上面wait函数不同的是,waitpid函数多出了pid和options参数,前者是等待终止的子进程ID,后者是条件选项,比如你传递WNOHANG常量作为options参数,程序就不会进入阻塞一直等子进程终止,而是返回0并退出函数。
信号通信
识别进程是否终结的工作是由操作系统来进行,所以不使用上面函数,我们可以由操作系统作为监控,在子进程终止时发送信息通知父进程。这就是信号处理机制。
信号也是一种资源,需要注册,以下的signal函数就实现注册信号功能并且在该信号产生时执行传参进去的函数指针指示函数。如下:
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
signo参数表示特殊情况,第二个参数为一个函数指针,用于此特殊情况,就是特殊情况发生时,调用func函数指针对应函数。signo参数对应的特殊情况有如下常数进行规定: SIGALRM:到通过调用alarm函数注册的时候了 SIGINT:输入CTRL + C SIGCHLD:子进程终止
这里使用的情况可以是这样: signal(SIGCHLD, myHandler); 以上调用就表明子进程终止时调用myHandler函数
sigaction的使用
但这里要使用的并不是signal函数,而是更通用的sigaction函数,因为前者在UNIX不同操作系统中有不同,但后者在UNIX中完全相同。
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {
void (*sa_handler)(int);
sigset_t sa_mask;
int sa_flags;
};
这里面出现了新的参数,sigaction结构体,函数中act参数是对应参数1的信号处理函数信息,oldact参数则是可以用来获取之前注册的信号处理函数指针,不需要可以传递0值。关于sigaction结构体的使用可以参考下面例子。 sigaction的使用
三、并发服务器的实现
针对前面的回声客户端进行更改,添加父子进程流程和信号处理函数即可,如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF 30
#include <signal.h>
#include <sys/wait.h>
void readChildProc(int sig);
void errorHandling(char* message);
int main(int argc, char *argv[]) {
pid_t pid;
struct sigaction act;
int state;
int serv_sock, clnt_sock;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t addr_size;
int str_len, i = 0;
char buf[BUF];
if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(-1);
}
act.sa_handler = readChildProc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
errorHandling("socket() error!");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr)) == -1)
errorHandling("bind() error!");
if (listen(serv_sock, 5) == -1)
errorHandling("listen() error!");
while(1) {
addr_size = sizeof(clnt_addr);
clnt_sock = accept(serv_sock, (struct sockaddr*) &clnt_addr, &addr_size);
if (clnt_sock == -1)
continue;
else
printf("第%d个客户端连接上了,IP:%s", ++i, inet_ntoa(clnt_addr.sin_addr));
pid = fork();
if (pid == -1) {
close(clnt_sock);
continue;
} else if (pid == 0) {
close(serv_sock);
while((str_len = read(clnt_sock, buf, BUF)) != 0)
write(clnt_sock, buf, str_len);
printf("Client: %s prepare to disconnect.\n", inet_ntoa(clnt_addr.sin_addr));
close(clnt_sock);
return 0;
} else
close(clnt_sock);
}
close(serv_sock);
return 0;
}
void errorHandling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(-1);
}
void readChildProc(int sig) {
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("removed process ID: %d", pid);
}
以上服务器的连接和断开连接的信息都在客户端断开连接的时候一起输出,让我有点不太懂,不确定是否都保留在缓存中。而且使用腾讯服务器运行回声客户端连接本机ubuntu子系统的回声服务端一如既往的connect() error。真让人懵逼。
理一理上面服务端的流程: 我们之前已经知道,服务端只有一个套接字用于监听客户端连接请求,然后另有套接字用于连接客户端并传输信息。然后从上面我们可以看到,服务端的父进程中套接字监听客户端请求,子进程中另有套接字负责连接客户端并传输信息,并且断开连接也是通过这个套接字。
注:当调用fork函数时,父子进程分别拥有一个前面clnt_sock对应的套接字,而在子进程区域有一个代码是close(serv_sock)是因为同样是复制给了子进程。
现在来理一理这个概念,fork函数复制了serv_sock和clnt_sock是复制了套接字文件描述符。socket套接字,它是一种资源,我们调用socket函数时,是创建了套接字资源,然后分配一个整数来称呼这个套接字,这个就是文件描述符。也就是说,资源就一种,但用来使用这个资源的文件描述符父子进程都有了,当它close以后,就是该进程断开了使用该资源的资格,具体我们可以参考指针概念,有一个变量,但指向该变量的指针有两个,被两个人拥有,然后他们都可以通过该指针来使用这个变量,文件描述符就类比这个指针。 那什么时候会销毁套接字资源?当然是所有文件描述符都终止,都被close才会被销毁。
前面的知识和实现都是和linux系统有关的,但windows不一样,我们在windows中都是进行的多线程,那我们要实现这么一个并发服务器要怎么办?
不急不急,其实并发这部分在linux中还没有完全ok,容我先买个关子。
|