??Socket是什么?
????Socket是一种进程之间通信的方法,允许同一主机或(通过网络连接起来的)不同主机上的应用程序进行数据交换。由于Socket起源于UNIX,继承自UNIX“一切皆文件”的思想,因此Socket本身就一种特殊的文件。
??操作Socket的核心——文件描述符
??既然Socket本身就是文件,那Socket函数(Socket API)对Socket的操作,本质上就是对文件的操作。内核为了高效管理打开的文件,会为每一个文件创建一个称为文件描述符(file descriptor)的索引。所有对文件进行I/O操作的系统调用,都需要经过文件描述符。因此,在编写Socket代码时,操作一个创建好的Socket,都需要经过文件描述符这一句柄。
??和Epoll什么关系?
????对于高并发的服务型程序,Socket连接需要处理大批量的文件描述符(连接数可达几十上百万),简单轮询如此大规模的文件描述符,普通的方法是行不通的。而Epoll就是为此而生的,能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。关于Epoll的详细介绍,会在之后的小节中展示!
??下图展示了Socket的通信模型(篇幅有限,以使用TCP协议为例)。每一个过程对应一个Socket API。在编写Socket程序时,需要遵循图中的顺序。
??下面对Socket API进行简单介绍。与上图是对应的,可以对比学习。
socket():
??函数在一个通信域(domain)中创建一个(未绑定的)Socket,并返回一个文件描述符,之后介绍的Socket API就可以使用该文件描述符对Socket进行操作啦!
??函数原型:int socket(int domain, int type, int protocol);
domain:指明一个创建Socket的通信域类型。
??PF_INET:协议族,指定协议时用;
??AF_INET:地址族,设置地址时用。
type:指明待创建Socket的类型。
??SOCK_STREAM:流服务,TCP协议;
??SOCK_DGRAM:数据报服务,UDP协议。
protocol:指明待创建Socket使用的协议。
????若为0,则使用适合请求Socket类型的默认协议(一般取0)
返回值:创建好的Socket的文件描述符,应为非负整数;若创建失败,则返回-1。
bind():
??为一个(由socket函数创建的)未命名Socket绑定一个Socket地址。
??函数原型:int bind(int socket, const struct sockaddr *address, socklen_t address_len);
socket:Socket的文件描述符。
address:指明一个要绑定的Socket地址(指向sockaddr结构的指针)。
address_len:指明Socket地址的长度(sockaddr结构体的长度)
????若为0,则使用适合请求Socket类型的默认协议(一般取0)
返回值:成功绑定,返回0,否则返回-1。
??注意Socket编程使用sockaddr结构体来管理Socket地址,而sockaddr_in结构体是其对应的Internet风格。二者关系具有相同的长度,并且可以相互强制转换指针。设计上类似基类和派生类的关系,Socket API的参数通常为更通用的“基类”sockaddr指针。在你想使用sockaddr_in指针作为参数时,需要先对其进行强制转换!
??sockaddr_in结构体字段:
struct sockaddr_in {
__uint8_t sin_len; // 结构体sin的长度
sa_family_t sin_family; // 地址族,必须设为AF_INET(表示IPv4协议)
in_port_t sin_port; // 端口(2B)
struct in_addr sin_addr; // IPv4地址(4B)
char sin_zero[8]; // 未使用,设置为0(8B)
};
// in_addr结构体表示一个IPv4地址
struct in_addr {
in_addr_t s_addr;
};
listen():
??监听Socket连接,并且可以限制监听队列长度(连接的数量)。
??函数原型:int listen(int socket, int backlog);
socket:Socket描述符。
backlog:指明监听队列长度(连接的数量)。
????若小于0,则设置为0;
????若大于Socket监听队列支持的最大长度,则设置为最大长度。
返回值:成功监听,返回0,否则返回-1。
accept():
??顾名思义,就是在Socket接受一个新的连接。具体来说是从监听队列中出队一个新的Socket连接,然后创建与其具有相同Socket类型协议和地址族的新Socket,并为之分配一个新的文件描述符。
??函数原型:int accept(int socket, struct sockaddr *address, socklen_t *address_len);
socket:正在监听的Socket描述符。
address:连接对方的Socket地址(指向sockaddr结构的指针)
address_len:连接对方的Socket的地址长度(sockaddr结构体的长度)
返回值:连接对方的Socket的文件描述符,应为非负整数;
????若创建失败,则返回-1。
send():
??在Socket上向连接的对方发送一条消息。
??函数原型:ssize_t send(int socket, const void *buffer, size_t length, int flags);
socket:Socket描述符。
buffer:指向包含所要发送消息的buffer数组。
length:指明消息长度(字节)。
flags:指明消息传输的类型,设置为0或者0与以下flag相或(|):
????MSG_EOR:终止一个记录;
????MSG_OOB:在支持带外通信的Socket上发送带外数据。
返回值:发送成功返回数据字节数,否则返回-1。
recv():
??从已连接的对方Socket接收信息
??函数原型:ssize_t recv(int socket, void *buffer, size_t length, int flags);
socket:Socket描述符。
buffer:指向用于接收消息的buffer。
length:指明消息长度(字节)。
flags:指明消息传输的类型,设置为0或者0与以下flag相或(|):
????MSG_EOR:终止一个记录;
????MSG_OOB:在支持带外通信的Socket上发送带外数据。
返回值:接收成功返回数据字节数,否则返回-1。
??在读者熟悉Socket API的使用后,可以尝试理解如下源码(server.c
、client.c
)。该程序中,Client需要向Server发起连接,以从Server获取需要的信息(14字节长的"Hello, World!\n"
)。Server则不断接受Client发起的请求,并向其发送信息。
??server.c:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netdb.h>
#define true 1
#define false 0
#define MYPORT 3490 // Server监听的端口
#define BACKLOG 10 // listen的请求接收队列长度
int main()
{
int sfd; // 存放创建好的服务器监听端口(sfd -> socket file descriptor)
if ((sfd = socket(PF_INET, SOCK_STREAM, 0)) == -1) {
perror("Socket创建失败!");
exit(1);
}
struct sockaddr_in sa; // 存放Server自身的Socket地址信息(sa -> socket address)
sa.sin_family = AF_INET;
sa.sin_port = htons(MYPORT); // htons将主机字节序转换为网络字节序
sa.sin_addr.s_addr = INADDR_ANY; // 自动填本机IP
memset(&(sa.sin_zero), 0, 8); // 其余部分置0
if (bind(sfd, (struct sockaddr *)&sa, sizeof(sa)) == -1) {
perror("Bind失败!");
exit(1);
}
if (listen(sfd, BACKLOG) == -1) {
perror("Listen失败!");
exit(1);
}
struct sockaddr_in gas; // 存放连接对方(客户端)的Socket地址信息(gas -> guest addresses)
unsigned int sin_size = sizeof(struct sockaddr_in); // sockaddr_in结构体的大小
// 主循环
while (true) {
int new_fd = accept(sfd, (struct sockaddr *)&gas, &sin_size);
if (new_fd == -1) {
perror("Accept失败!");
continue;
}
printf("获得来自%s的连接\n", inet_ntoa(gas.sin_addr));
if (fork() == 0) { // 子进程,fork返回0
if (send(new_fd, "Hello, World!\n", 14, 0) == -1)
perror("send");
close(new_fd);
exit(0);
}
close(new_fd);
while (waitpid(-1, NULL, WNOHANG) > 0); // 清除所有子进程
}
}
??client.c:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netdb.h>
#define true 1
#define false 0
#define PORT 3490 // Server监听的端口
#define MAXDATASIZE 100 // 客户端可读入的最大字节数
int main(int argc, char *argv[]) {
if (argc != 2) { // 参数数量不对
fprintf(stderr, "命令使用方法:client hostname\n");
exit(1);
}
struct hostent *he; // 主机(服务器)信息
if ((he = gethostbyname(argv[1])) == NULL) {
perror("Gethostbyname失败!");
exit(1);
}
int sfd; // 存放创建好的客户端监听端口(sfd -> socket file descriptor)
if ((sfd = socket(PF_INET, SOCK_STREAM, 0)) == -1) {
perror("Socket创建失败!");
exit(1);
}
struct sockaddr_in sa; // 服务器地址信息
sa.sin_family = AF_INET;
sa.sin_port = htons(PORT); // htons将主机字节序转换为网络字节序
sa.sin_addr = *((struct in_addr *)he -> h_addr_list[0]); // 指定服务器IP
memset(&(sa.sin_zero), 0, 8); // 其余部分置0
if (connect(sfd, (struct sockaddr *)&sa, sizeof(struct sockaddr)) == -1) {
perror("Connect失败!");
exit(1);
}
int numbytes;
char buf[MAXDATASIZE];
if ((numbytes = recv(sfd, buf, MAXDATASIZE, 0)) == -1) {
perror("recv");
exit(1);
}
buf[numbytes] = '\0';
printf("接收到数据: %s", buf);
close(sfd);
return true;
}
??运行效果:
????先将源码编译为可执行文件:
gcc client.c -o client
gcc server.c -o server
????开启一个终端执行server:
????开启另一个终端执行client:
????可以观察到Server端输出了成功获得Client端的连接信息,而Client端也成功从Server端获取到需要的数据。
多路:多个业务方(句柄)并发下来的 IO 。
复用:复用同一个服务端程序
??网卡是怎么接收到网络上的数据的?
????网卡从网线接收到传来的数据,再经过硬件电路的传输,最终将数据写入内存的某个地址上。
??CPU怎么知道接收了数据?
????当网卡通过上面的过程将数据写入内存后,网卡就会向CPU发送中断信号,以告知有数据到来。
??阻塞原理
????从进程调度的角度来看,若进程在等待某一事件(如等待接收网络数据),则会在事件发生之前进入阻塞状态(也叫等待状态)。Socket API中的recv函数和epoll本质上都是阻塞方法。
????下面通过一个例子来理解阻塞过程。假设一个接收客户端消息的服务器的Socket代码具有以下结构:
int sfd = socket(AF_INET, SOCK_STREAM, 0);
bind(sfd, ...);
listen(sfd, ...);
int new_sfd = accept(sfd, ...);
recv(sfd, ...); // 会发生阻塞!
????在程序执行到recv时,就会进入阻塞状态一直等待,直到接收到数据才会往下执行。如下图所示,假设进程1会执行上面这个Socket程序。
????在进程1被操作系统调度后,会为其创建一个由文件系统管理的Socket对象,包含发送缓冲区、接受缓冲区和等待队列等成员。等待队列指向了所有需要等待该Socket事件的进程。当进程1执行到recv函数时,会因为等待数据而被加入该Socket对象的等待队列中,如下图所示:
????主机从网线接收数据到网卡、写入内存以后,CPU接收到中断信号进行中断处理,接收数据。直到数据接收完成,才会唤醒进程1,重新放回工作队列中。
????从上面这个单个连接的例子来看,Socket程序线性执行过程中的阻塞过程是简单明了的。但存在多个连接、多个Socket对象时,又如何知道哪些数据到达、唤醒哪些进程呢?这就需要使用到epoll技术了。
epoll是什么?
??epoll是一个Linux内核系统调用,用于可扩展的I/O事件通知机制,在Linux内核的2.5.45版本中被首次引入。 它维护一个监视列表(=兴趣列表),监视多个文件描述符,查看是否可以在其中任何一个文件上进行I/O操作。相比旧的select和poll系统调用(O(n)),能在要求更高的应用程序中实现更好的性能(O(1))。
应用场景:
??Epoll经常应用于Linux下高并发服务型程序。尤其适合大量并发连接中只有少部分连接处于活跃下的情况。在这种情况下Epoll能显著的提高程序的CPU利用率。
四个特点:
多路复用
事件驱动
水平触发和边缘触发
高性能
epoll底层数据结构
??epoll使用红黑树来跟踪当前被监视的所有文件描述符。
??epoll API都有一个文件描述符参数,表示协同操作的可配置内核对象。
??创建一个epoll对象并返回一个文件描述符。epoll_create()是老版本的epoll_create1() ,在Linux内核版本2.6.27和glibc版本2.9中被废除。
??函数原型:int epoll_create1(int flags);
flags:用于改变epoll的行为,不改变取0,除此以外只有EPOLL_CLOEXEC一种特殊取值。
返回值:创建成功,返回一个非负整数的文件描述符,否则返回-1。
??用于控制(配置)epoll对象监视的文件描述符和事件。
??函数原型:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd:epoll_create1()创建得到的文件描述符。
op:执行在目标文件描述符fd上的操作。
??EPOLL_CTL_ADD:在epoll文件描述符epfd的兴趣列表中添加一条记录。记录包括文件描述符fd、对fd相应打开文件描述的引用,以及通过event声明的设置。
??EPOLL_CTL_MOD:将兴趣列表中fd的设置改变为event中新指定的设置。
??EPOLL_CTL_DEL:从兴趣列表删除fd的记录。event中指定的设置将被忽略(可直接设置为NULL)。
fd:被操作的文件描述符fd。
event:指向fd所连接的对象(epoll_event结构体)。epoll_event结构体介绍见后文。
返回值:若成功返回0,否则返回-1。
??epoll_event结构体:
????该结构体指明了内核应该存储的数据,以及在数据准备好时应该返回的对应文件描述符。
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
};
typedef union epoll_data epoll_data_t;
????其中,数据成员data指明了内核应该存储的数据,以及在数据准备好时应该返回的对应文件描述符。数据成员events则是由0或者其他event类型相或(|)所得的值,用于影响event的行为。
event类型详见
??等待一个epoll文件描述符上的I/O事件。调用epoll_wait()会一直阻塞,直到以下情况发生:
??(1) 一个文件描述符触发了一个事件;
??(2) 调用被信号处理程序中断;
??(3) 超过参数timeout指定的时间。
??函数原型:int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epfd:epoll_create1()创建得到的文件描述符。
events:指向调用者可以使用的事件的内存区域。
maxevents:告知内核events的数量,必须大于0。
timeout:指明阻塞的毫秒数,时间是根据CLOCK_MONOTONIC时钟测量的。timeout == -1,函数会永远阻塞下去;timeout == 0,立刻返回。
返回值:若成功返回0,否则返回-1。
??当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(即文件描述符epfd所代表的对象)。
??utility.h:
#ifndef UTILITY_H_INCLUDED
#define UTILITY_H_INCLUDED
#include <iostream>
#include <list>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define true 1
#define false 0
using namespace std;
list<int> clientsList; // 存放所有用户的fd
#define SERVER_IP "127.0.0.1" // 服务器IP
#define SERVER_PORT 8888 // 服务器端口号
#define EPOLL_SIZE 5000 // epoll大小
#define BUF_SIZE 0xFFFF // 缓冲区大小
#define SERVER_WELCOME "欢迎加入聊天室!\n您的ID是: #%d" // 聊天室欢迎用户信息格式
#define SERVER_MESSAGE "用户 #%d >> %s" // 用户发言信息格式
#define EXIT "EXIT"
#define CAUTION "当前只有您一名用户,无法聊天!"
int setnonblocking(int sockfd) {
fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0) | O_NONBLOCK);
return 0;
}
void addfd(int epollfd, int fd, bool enable_et ) {
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN;
if( enable_et )
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);
setnonblocking(fd);
}
int sendBroadcastmessage(int clientfd) {
char buf[BUF_SIZE]; // 接收新的聊天信息
char message[BUF_SIZE]; // 存放格式化的信息
bzero(buf, BUF_SIZE);
bzero(message, BUF_SIZE);
// receive message
printf("接收到来自用户#%d的消息\n", clientfd);
int len = recv(clientfd, buf, BUF_SIZE, 0);
if (len <= 0) { // 用户关闭连接
close(clientfd);
clientsList.remove(clientfd); // 服务器移除用户
printf("用户#%d关闭.\n 聊天室现有%d名用户\n", clientfd, (int)clientsList.size());
}
else { // 广播消息
if (clientsList.size() == 1) { // 只有1名用户在聊天室中
send(clientfd, CAUTION, strlen(CAUTION), 0);
return len;
}
sprintf(message, SERVER_MESSAGE, clientfd, buf);
for (list<int>::iterator it = clientsList.begin(); it != clientsList.end(); ++it) {
if(*it != clientfd){
if(send(*it, message, BUF_SIZE, 0) < 0 ) {
perror("发送失败!");
exit(-1);
}
}
}
}
return len;
}
#endif // UTILITY_H_INCLUDED
??epoll_server.cpp:
#include "utility.h"
int main(int argc, char *argv[]) {
// 设置服务器Socket地址
struct sockaddr_in serverAddr;
serverAddr.sin_family = PF_INET;
serverAddr.sin_port = htons(SERVER_PORT);
serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);
// 创建监听的Socket
int sfd = socket(PF_INET, SOCK_STREAM, 0);
if (sfd < 0) {
perror("Socket创建失败!");
exit(-1);
}
// 绑定Socket地址
if(bind(sfd, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) {
perror("Bind失败!");
exit(-1);
}
// 监听
int ret = listen(sfd, 5);
if (ret < 0) {
perror("Listen失败!");
exit(-1);
}
printf("开始监听: %s\n", SERVER_IP);
// 在内核中创建事件表
int epfd = epoll_create(EPOLL_SIZE);
if (epfd < 0) {
perror("epfd error");
exit(-1);
}
printf("创建epoll, epfd = %d\n", epfd);
static struct epoll_event events[EPOLL_SIZE];
// 向内核事件表中添加事件
addfd(epfd, sfd, true);
// 主循环
while (true) {
int cnt = epoll_wait(epfd, events, EPOLL_SIZE, -1); // 记录就绪事件的数目
if(cnt < 0) {
perror("epoll失败!");
break;
}
printf("就绪事件数目: %d\n", cnt);
// 处理就绪事件(共cnt个)
for (int i = 0; i < cnt; i++) {
int new_sfd = events[i].data.fd;
//新用户连接
if (new_sfd == sfd) {
struct sockaddr_in ca;
socklen_t client_addrLength = sizeof(struct sockaddr_in);
int clientfd = accept(sfd, (struct sockaddr*)&ca, &client_addrLength );
printf("用户连接: %s : %d(IP : port), 用户fd = %d\n", inet_ntoa(ca.sin_addr),
ntohs(ca.sin_port), clientfd);
addfd(epfd, clientfd, true); // 把这个新的客户端添加到内核事件列表
// 服务端用list保存用户连接
clientsList.push_back(clientfd);
printf("加入新的用户fd = %d 至epoll中\n", clientfd);
printf("当前有%d名用户在聊天室中\n", (int)clientsList.size());
// 服务端发送欢迎信息
printf("欢迎!\n");
char message[BUF_SIZE];
bzero(message, BUF_SIZE);
sprintf(message, SERVER_WELCOME, clientfd);
int ret = send(clientfd, message, BUF_SIZE, 0);
if (ret < 0) {
perror("Send失败!");
exit(-1);
}
}
//客户端唤醒//处理用户发来的消息,并广播,使其他用户收到信息
else {
int ret = sendBroadcastmessage(new_sfd);
if(ret < 0) { perror("error");exit(-1); }
}
}
}
close(sfd);
close(epfd);
return 0;
}
??epoll_client.cpp:
#include "utility.h"
int main(int argc, char *argv[]) {
// 设置服务器Socket地址
struct sockaddr_in sa;
sa.sin_family = PF_INET;
sa.sin_port = htons(SERVER_PORT); // htons将主机字节序转换为网络字节序
sa.sin_addr.s_addr = inet_addr(SERVER_IP);
// 创建socket
int sfd = socket(PF_INET, SOCK_STREAM, 0);
if (sfd < 0) {
perror("Socket创建失败!");
exit(-1);
}
// 连接服务端
if (connect(sfd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
perror("Connect失败!");
exit(-1);
}
// 创建管道,fd[0]用于父进程读,fd[1]用于子进程写
int pipe_fd[2];
if (pipe(pipe_fd) < 0) {
perror("pipe error");
exit(-1);
}
// 创建epoll
int epfd = epoll_create(EPOLL_SIZE);
if (epfd < 0) { perror("epfd error"); exit(-1); }
static struct epoll_event events[2];
//将sock和管道读端描述符都添加到内核事件表中
addfd(epfd, sfd, true);
addfd(epfd, pipe_fd[0], true);
// 表示客户端是否正常工作
bool clientSta = true;
// 聊天信息缓冲区
char message[BUF_SIZE];
// Fork
int pid = fork();
if(pid < 0) {
perror("fork出错!");
exit(-1);
}
else if(pid == 0) { // 子进程
close(pipe_fd[0]); // 子进程负责写,因此先关闭读端
printf("请输入'exit'退出聊天室\n");
while (clientSta) {
bzero(&message, BUF_SIZE);
fgets(message, BUF_SIZE, stdin);
// 客户输出'exit',退出
if(strncasecmp(message, EXIT, strlen(EXIT)) == 0)
clientSta = 0;
else { // 子进程将信息写入管道
if (write(pipe_fd[1], message, strlen(message) - 1) < 0) {
perror("fork出错!");
exit(-1);
}
}
}
}
else { // 父进程
//父进程负责读,因此先关闭写端
close(pipe_fd[1]);
// 主循环(epoll_wait)
while (clientSta) {
int epoll_events_count = epoll_wait( epfd, events, 2, -1 );
// 处理就绪事件
for (int i = 0; i < epoll_events_count; i++) {
bzero(&message, BUF_SIZE);
// 服务端发来消息
if (events[i].data.fd == sfd) {
//接受服务端消息
int ret = recv(sfd, message, BUF_SIZE, 0);
// ret= 0 服务端关闭
if(ret == 0) {
printf("Server closed connection: %d\n", sfd);
close(sfd);
clientSta = 0;
}
else printf("%s\n", message);
}
//子进程写入事件发生,父进程处理并发送服务端
else {
int ret = read(events[i].data.fd, message, BUF_SIZE); // 父进程从管道中读取数据
if (ret == 0)
clientSta = 0;
else // 将信息发送给服务端
send(sfd, message, BUF_SIZE, 0);
}
}
}
}
if (pid) {
// 关闭父进程和Socket
close(pipe_fd[0]);
close(sfd);
}
else {
// 关闭子进程
close(pipe_fd[1]);
}
return 0;
}
??运行效果:
????先将源码编译为可执行文件:
gcc -lstdc++ epoll_server.cpp -o server
gcc -lstdc++ epoll_client.cpp -o client
????开启一个终端运行服务端程序:
????开启另一个终端运行客户端程序:
????此时服务端也有用户加入的记录:
????由于只有一个用户,因此无法聊天:
????再开启另一个终端运行客户端程序,加入聊天室,即可进行聊天: