简单的TCP网络编程
本文中用到的内容可在另一篇博客中见到
UDP套接字编程
一. 初识TCP协议
TCP
叫做传输控制协议。它具有以下特点:
(1)传输层的协议
(2)面向连接:基于TCP进行网络通信时,要先建立连接,建立成功才能通信。而UDP不需要连接可直接通信,所以相对而言,TCP的速度较慢,因为建立和维护连接需要花费资源。
(3)可靠传输:相对UDP而言,TCP增加了出错重传等机制,以确保数据的可靠性,而UDP则没有,这也是UDP较快的一个原因
(4)面向字节流:UDP面向数据报,所以发送方发来一个数据报,它要一次全部接收;而TCP面向字节流,不管发送方发来多少数据,接收方一次可以接收任意长度的数据。
二. TCP相关的接口函数
1. listen函数
(1)函数原型:
(2)函数功能:将客户端的套接字变为被动连接套接字,使一个进程可以接受其他进程的请求,即只有处理监听状态的套接字,才可以被客户端连接
(3)函数参数:
sockfd:套接字的文件描述符,即服务器端的已绑定但是未连接的套接字
backlog:连接请求队列(一般是5)
注意:
当系统资源不够支持服务器端对所有发送请求的客户端进行处理时,发送连接请求的客户端就要在底层排序,等待服务器端可以来处理它们。
首先,为了使服务器端一直处于忙碌状态,从而实现资源的利用最大化。所以我们有了底层的等待队列,这样能够保证服务器端实现对资源的最大利用化。
同时,等待队列不能过长。一个是因为等待队列过长时,有的客户端会迟迟得不到处理,而一直等待可能会使得它做不了其他事;再一个是因为等待队列也需要花费资源来维护,队列过长,耗费的资源也越多,我们应该将更多地资源用以服务。所以队列的长度一般为5,当请求超过5个时,多余的会被直接忽略。
2. connect函数
(1)函数原型:
(2)函数功能:客户端通过该函数向服务器端发送连接请求
(3)函数参数:
sockfd:客户端的套接字
addr:保存目的IP地址与目的端口号的结构体指针
addrlrn:参数addr的长度
(4)返回值:成功返回0,失败返回-1
3. accept函数
(1)函数原型:
(2)函数功能:在调用connect函数成功客户端与服务器端成功建立连接后,即三次握手完成后,服务器调用accept函数接受连接
(3)函数参数:
sockfd:服务器端的套接字
addr:
指向已连接的客户端的套接字的结构体,若设置为NULL,就表示服务器端不关心客户端地址,是输出型参数
addrlen:参数addr的长度
(4)函数返回值:输入输出型参数,传入的是调用者提供的缓冲区addr的长度,以避免缓冲区溢出。输出的是实际客户端结构体变量addr的长度,此时,可能没有占满调用者提供的缓冲区的大小
三. TCP套接字编程实现
1. 服务器端(
基于单进程连接)实现
实现过程:
(1)在服务器端要调用socket打开一个网卡文件用于网络通信
(2)调用bind函数将服务器程序与特定的IP地址和端口号进行绑定,以便客户端能找到该服务器与之连接通信
(3)服务器基于TCP协议,所以要使上述的网卡文件处于监听状态才能接受客户端发来的连接请求
(4)当客户端调用connect函数与服务器端建立连接成功后,服务器需要调用accept函数来接收连接
(5)双方开始进行通信
(6)因为可能有多个客户端会向服务器发送建立连接请求,所以服务器需要不断的调用accept函数来接受连接请求,所以应该让(4)(5)处于一个死循环中。
实现代码:
//TCP服务器,只能允许一个人连接 #include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <stdlib.h> #include <arpa/inet.h> #include <unistd.h> #include <netinet/in.h> #include <string.h> #define MAXSIZE 128 int startup(int port, char* ip) { //1.创建套接字,这里是流式的套接字,因为TCP面向字节流 int sock = socket(AF_INET, SOCK_STREAM, 0); if(sock < 0) { printf("socket error\n"); exit(2);//套接字创建失败,直接终止进程,因为没有套接字网络通信根本无法实现,后续代码根本不用执行 } //2.绑定 struct sockaddr_in local; local.sin_family = AF_INET; local.sin_port = htons(port);//端口号 local.sin_addr.s_addr = inet_addr(ip);//IP if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0) { printf("bind error\n"); exit(3); } //3.监听:一直等待客户来连接(因为TCP是面向连接的),提取新连接; if(listen(sock, 5) < 0)//最多容纳5个客户连接请求 { printf("listen error\n"); exit(4); } return sock;//返回一个监听套接字 } void service(int sock, char* ip, int port) { char buf[MAXSIZE]; while(1) { //TCP是流式套接字,可直接用读写文件的接口操作 //read返回值为0时,说明对方(客户端)已关闭 ssize_t s = read(sock, buf, sizeof(buf)-1); if(s > 0) { buf[s] = 0; printf("[%s:%d]say# %s\n", ip, port, buf); //TCP套接字是全双工的,两个可以同时读写 write(sock, buf, strlen(buf)); } else if(s == 0) { printf("client [%s:%d] quit!\n", ip, port); break; } else { printf("read error\n"); break; } } } //./tcp_server 127.0.0.1 8080 int main(int argc, char* argv[]) { if(argc != 3) { printf("Usage: %s [ip] [port]\n", argv[0]); exit(1); } //创建套接字 int listen_sock = startup(atoi(argv[2]), argv[1]); struct sockaddr_in peer; char ipbuf[24]; for( ; ; ) { ipbuf[0] = 0; socklen_t len = sizeof(peer); //从监听套接字中拿连接 int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len); if(new_sock < 0)//拿连接失败,不用管,因为还可以去拿其他连接 { printf("accept error\n"); continue; } //获得了一个新连接 inet_ntop(AF_INET, (const void*)&peer.sin_addr, ipbuf, sizeof(ipbuf));//将四字节IP地址转换为点分十进制 int p = ntohs(peer.sin_port);//端口号:网络序列转换为主机序列的端口号 printf("get a new connect,[%s:%d]\n", ipbuf, p);//将新连接的IP和端口号打印 //提供服务 service(new_sock, ipbuf, p); close(new_sock); } return 0; }
2. 客户端实现
实现过程:
(1)客户端端先打开网卡文件,得到文件描述符
(2)客户端不需要绑定固定的端口号,它的端口号是由内核自动进行分配。所以直接调用connect函数向服务器端发送连接请求
(3)当连接成功,服务器端调用accept函数接收客户端的连接请求后,双方便开始进行通信,通信完毕需要关闭网卡文件
实现代码:
//TCP客户端 #include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <stdlib.h> #include <arpa/inet.h> #include <unistd.h> #include <netinet/in.h> #include <string.h> #define MAXSIZE 128 //./tcp_client 127.0.0.1 8080 int main(int argc, char* argv[]) { if(argc != 3) { printf("Usage: %s [ip] [port]\n", argv[0]); return 1; } //创建套接字 int sock = socket(AF_INET, SOCK_STREAM, 0); if(sock < 0) { printf("socket error\n"); return 2; } //客户端不用绑定(bind),不用监听(listen),不用获取新连接(accept) //客户端有一个个性化操作connect,向服务器发起连接 struct sockaddr_in server; server.sin_family = AF_INET; server.sin_port = htons(atoi(argv[2])); server.sin_addr.s_addr = inet_addr(argv[1]); if(connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0) { printf("connect error\n"); return 3; } //走到这,连接成功 char buf[MAXSIZE]; while(1) { printf("please enter# "); fflush(stdout); ssize_t s = read(0, buf, sizeof(buf)-1); if(s > 0) { buf[s-1] = 0;//去掉回车符号 if(strcmp("quit", buf) == 0)//客户端输入quit表示客户端退出 { printf("client quit\n"); break; } write(sock, buf, strlen(buf)); s = read(sock, buf, sizeof(buf)-1); buf[s] = 0; printf("serevr echo# %s\n",buf); } } close(sock); return 0; }
与UDP一样,我们提供本地环回测试,测试结果如下:
图左为服务器端,图右为客户端。
上述代码的实现过程,在一个客户端与服务器连接之后进行通信时,其他客户端会连接不上。因为,上述客户端的代码实现中,当一个客户端连接之后,服务器进入到与该客户端通信的死循环中,所有服务器端无法调用accept函数接受其他客户端发来的请求,直至该客户端断开连接之后。
然而在实际应用中,服务器应该可以同时处理多个客户端的请求,所以上述服务器端代码还需要改进。
3. 基于多进程连接的服务器端实现
实现过程:
我们可以通过创建子进程的方式提供多个执行流,当一个客户端发来请求时,主进程创建一个子进程与客户端进行通信,父进程的任务仅仅是不断的接收新的客户端请求,创建子进程与之通信。要注意的就是,子进程完成通信后退出,要回收它的资源并释放。
所以我们将上面的单进程版本,修改main函数如下即可:
//./tcp_server 127.0.0.1 8080 int main(int argc, char* argv[]) { if(argc != 3) { printf("Usage: %s [ip] [port]\n", argv[0]); exit(1); } //创建套接字 int listen_sock = startup(atoi(argv[2]), argv[1]); struct sockaddr_in peer; char ipbuf[24]; for( ; ; ) { ipbuf[0] = 0; socklen_t len = sizeof(peer); //从监听套接字中拿连接 int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len); if(new_sock < 0)//拿连接失败,不用管,因为还可以去拿其他连接 { printf("accept error\n"); continue; } //获得了一个新连接 inet_ntop(AF_INET, (const void*)&peer.sin_addr, ipbuf, sizeof(ipbuf));//将四字节IP地址转换为点分十进制 int p = ntohs(peer.sin_port);//端口号:网络序列转换为主机序列的端口号 printf("get a new connect,[%s:%d]\n", ipbuf, p);//将新连接的IP和端口号打印 //父进程获取新连接,子进程为其提供服务,提供完子进程退出;然后父进程再获取,子进程再通过 pid_t id = fork(); if(id == 0)//child提供服务 { //子进程继承父进程文件描述符表,打开了两个套接字:listen_sock、new_sock, //而子进程不需要listen_sock,防止意外,所以关闭它 close(listen_sock); if(fork() > 0)//它退出后,它的子进程变成孤儿进程,被1号进程回收 { exit(0); } //提供完服务,就关闭,然后子进程退出 service(new_sock, ipbuf, p); close(new_sock); exit(0); } else if(id > 0)//father获得新连接 { //上面子进程可以不关listen_scok,因为不去访问它即可,但是这里父进程必须关闭 //因为每次创建子进程,都会给它文件描述符,这里不关,创建一个子进程给一个文件描述符, //但文件描述符有上限,所以关闭它 close(new_sock); //父进程需要等待子进程 //否则每次创建的子进程退出了,但是父进程不退出,循环下去会产生很多僵尸进程,可能导致内存泄漏问题 //阻塞等待让父进程卡住,损耗效率;非阻塞等待也不好用,也有可能造成内存泄漏;信号章节的等待方式有局限性 waitpid(id, NULL, 0); } else//创建失败,因为系统压力太大,不能承受新连接 { printf("fork error\n"); continue; } } return 0; }
这里注意一下用以提供服务的子进程的释放方法。
4. 基于多进程的服务器版本
实现思想:
我们也可以让服务器通过创建多线程的方法来提供多个执行流,从而实现多个客户端可以同时连接服务器。主线程接收客户端发来的请求并创建新线程,新线程与客户端进行通信。在上述多进程的程序中主要考虑的问题是父进程在阻塞等待时不能接收连接请求。多线程环境中主线程理应对新线程进行回收,可以通过线程分离来实现。当新线程退出时自己回收资源,不必主线程来回收,所以主线程在创建完新线程之后,直接对其进行分离,就可以连续不断接受新的连接请求了。
实现代码:
//TCP多线程服务器 #include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <stdlib.h> #include <arpa/inet.h> #include <unistd.h> #include <netinet/in.h> #include <string.h> #include <pthread.h> #define MAXSIZE 128 typedef struct { int sock; char ip[24]; int port; }net_info_t; int startup(int port, char* ip) { //1.创建套接字,这里是流式的套接字,因为TCP面向字节流 int sock = socket(AF_INET, SOCK_STREAM, 0); if(sock < 0) { printf("socket error\n"); exit(2);//套接字创建失败,直接终止进程,因为没有套接字网络通信根本无法实现,后续代码根本不用执行 } //2.绑定 struct sockaddr_in local; local.sin_family = AF_INET; local.sin_port = htons(port);//端口号 local.sin_addr.s_addr = inet_addr(ip);//IP if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0) { printf("bind error\n"); exit(3); } //3.监听:一直等待客户来连接(因为TCP是面向连接的),提取新连接; if(listen(sock, 5) < 0)//最多容纳5个客户连接请求 { printf("listen error\n"); exit(4); } return sock;//返回一个监听套接字 } void service(int sock, char* ip, int port) { char buf[MAXSIZE]; while(1) { //TCP是流式套接字,可直接用读写文件的接口操作 //read返回值为0时,说明对方(客户端)已关闭 ssize_t s = read(sock, buf, sizeof(buf)-1); if(s > 0) { buf[s] = 0; printf("[%s:%d]say# %s\n", ip, port, buf); //TCP套接字是全双工的,两个可以同时读写 write(sock, buf, strlen(buf)); } else if(s == 0) { printf("client [%s:%d] quit!\n", ip, port); break; } else { printf("read error\n"); break; } } } void* thread_service(void* arg) { net_info_t *p = (net_info_t* )arg; service(p->sock, p->ip, p->port); close(p->sock); free(p); } //./tcp_server 127.0.0.1 8080 int main(int argc, char* argv[]) { if(argc != 3) { printf("Usage: %s [ip] [port]\n", argv[0]); exit(1); } //创建套接字 int listen_sock = startup(atoi(argv[2]), argv[1]); struct sockaddr_in peer; char ipbuf[24]; for( ; ; ) { ipbuf[0] = 0; socklen_t len = sizeof(peer); //从监听套接字中拿连接 int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len); if(new_sock < 0)//拿连接失败,不用管,因为还可以去拿其他连接 { printf("accept error\n"); continue; } //获得了一个新连接 inet_ntop(AF_INET, (const void*)&peer.sin_addr, ipbuf, sizeof(ipbuf));//将四字节IP地址转换为点分十进制 int p = ntohs(peer.sin_port);//端口号:网络序列转换为主机序列的端口号 printf("get a new connect,[%s:%d]\n", ipbuf, p);//将新连接的IP和端口号打印 //这里不用像多进程的版本关闭多余的文件描述符 //因为线程共享地址空间,关掉一个文件,其他线程就看不到用不了了 net_info_t* info = (net_info_t*)malloc(sizeof(net_info_t)); if(info == NULL) { perror("malloc"); close(new_sock); continue; } info->sock = new_sock; strcpy(info->ip,ipbuf); info->port = p; pthread_t tid; pthread_create(&tid, NULL, thread_service, (void* )info); pthread_detach(tid);//线程分离后,该线程的资源会自动释放 } return 0; }
多进程版本和多线程版本的服务器代码都可以像单进程版本的方法测试,但是要注意用多台机器连接服务器端去测试。
四. 多进程与多线程版本的服务器的优缺点
1. 多进程
(1)优点
1)能够处理多个用户;
2)简单,编写周期短;
3)稳定性强,一个进程挂掉不会影响其他进程
(2)缺点
1)客户端连接后才创建子进程,性能受损
2)每个进程特别吃资源,导致能服务的客户数是有上限的,且上限较低
3)随客户的增多,CPU的调度压力也会增大,性能受到影响,客户端等待周期长
2. 多线程
(1)优点
1)能处理多个用户
2)简单,编写周期短
(2)缺点
1)每个线程也吃资源,导致能服务的客户数有上限,但是相比多进程较好一点
2)随客户增多,CPU调度压力增大,但没多进程调度压力大
3)稳定性差,可能会因为线程的安全问题使得服务器挂掉
五.TCP协议与UDP协议对比
1. TCP面向连接,UDP无连接
2. TCP面向字节流,UDP面向数据报
3.TCP可靠传输,UDP不可靠传输
所以,TCP的传输速度比UDP慢,消耗的资源也比UDP多,消耗的时间也比UDP多。