TCP的socket编程

TCP客户端逻辑

cpp 复制代码
void Usage(const std::string & process) {
    std::cout << "Usage: " << process << " server_ip server_port" <<
        std::endl;
}
// ./tcp_client serverip serverport
int main(int argc, char * argv[]) {
    if (argc != 3) {
        Usage(argv[0]);
        return 1;
    }
    std::string serverip = argv[1];
    uint16_t serverport = stoi(argv[2]);
    // 1. 创建 socket
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        cerr << "socket error" << endl;
        return 1;
    }
    // 要不要 bind?

    // 2. connect
    struct sockaddr_in server;
    memset( & server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    // p:process(进程), n(网络) -- 不太准确,但是好记忆
    inet_pton(AF_INET, serverip.c_str(), & server.sin_addr); // 1. 字符串 ip->4 字节 IP 2. 网络序列
    int n = connect(sockfd, CONV( & server), sizeof(server)); // 自动进行 bind
    if (n < 0) {
        cerr << "connect error" << endl;
        return 2;
    }
    // 未来我们就用 connect过后的的sockfd 进行通信即可
    while(true) {
        string inbuffer;
        cout << "Please Enter# ";
        getline(cin, inbuffer);
        ssize_t n = write(sockfd, inbuffer.c_str(),
            inbuffer.size());
        if (n > 0) {
            char buffer[1024];
            ssize_t m = read(sockfd, buffer, sizeof(buffer) - 1);
            if (m > 0) {
                buffer[m] = 0;
                cout << "get a echo messsge -> " << buffer <<
                    endl;
            } else if (m == 0 || m < 0) {
                break;
            }
        } else {
            break;
        }
    }
    close(sockfd);
    return 0;
}

1.创建tcp套接字,创建服务器监听套接字地址结构体

我们是没有给客户端的通信套接字绑定地址的,要注意,我们不能手动bind客户端通信套接字,为什么?

手动绑定意味着客户端通信套接字的ip和端口是死的,那服务器端监听套接字的ip和端口也是死的,那假如创建多个客户端,则三次握手后这几个连接的五元组都是一样的,发送消息最后就无法正确给到套接字,玩不了一点

那咋整,有个接口叫个connect,你传给它套接字描述符,它来给你自动绑定套接字地址(ip和端口),这样最后每个连接的五元组都不会一样了

2.接下来调用connect(sockfd, CONV( & server), sizeof(server))

这个接口会先给客户端套接字绑定地址(ip和端口),然后会发起三次握手建立连接,建立连接之后,客户端通信套接字就用五元组(协议,源ip,源port,目的ip,目的port)来标识

3.因为是有连接的,所以和udp相比起来就是省点事,udp无连接,所以要用sendto,recvfrom来发送和接收消息,但是tcp不需要,直接用write和read就行

TCP多进程版本服务器

cpp 复制代码
const static int default_backlog = 6;
// TODO
class TcpServer: public nocopy {
	public: TcpServer(uint16_t port): _port(port),
	        _isrunning(false) {
	}
	// 都是固定套路
	void Init() {
		// 1. 创建 socket, 本质是文件
		_listensock = socket(AF_INET, SOCK_STREAM, 0);
		if (_listensock < 0) {
			lg.LogMessage(Fatal, "create socket error, errno
                    code: % d, error string: % s\ n ", errno, strerror(errno));
			exit(Fatal);
		}
		int opt = 1;
		setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR |
		                    SO_REUSEPORT, & opt, sizeof(opt));
		lg.LogMessage(Debug, "create socket success,
                        sockfd: % d\ n ", _listensock);
		// 2. 填充本地网络信息并 bind
		struct sockaddr_in local;
		memset( & local, 0, sizeof(local));
		local.sin_family = AF_INET;
		local.sin_port = htons(_port);
		local.sin_addr.s_addr = htonl(INADDR_ANY);
		// 2.1 bind
		if (bind(_listensock, CONV( & local), sizeof(local)) != 0) {
			lg.LogMessage(Fatal, "bind socket error, errno
                                code: % d, error string: % s\ n ", errno, strerror(errno));
			exit(Bind_Err);
		}
		lg.LogMessage(Debug, "bind socket success, sockfd: %d\n",
		                                _listensock);
		// 3. 设置 socket 为监听状态,tcp 特有的
		if (listen(_listensock, default_backlog) != 0) {
			lg.LogMessage(Fatal, "listen socket error, errno code: % d, error string: % s\ n ", errno, strerror(errno));
			exit(Listen_Err);
		}
		lg.LogMessage(Debug, "listen socket success,
                                sockfd: % d\ n ", _listensock);
	}
	// Tcp 连接全双工通信的.
	void Service(int sockfd) {
		char buffer[1024];
		// 一直进行 IO
		while (true) {
			ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
			if (n > 0) {
				buffer[n] = 0;
				std::cout << "client say# " << buffer <<
				                                                std::endl;
				std::string echo_string = "server echo# ";
				echo_string += buffer;
				write(sockfd, echo_string.c_str(),
				                                                echo_string.size());
			} else if (n == 0) // read 如果返回值是 0,表示读到了文件结
			尾(对端关闭了连接!) {
				lg.LogMessage(Info, "client quit...\n");
				break;
			} else {
				lg.LogMessage(Error, "read socket error, errno
                                                code: % d, error string: % s\ n ", errno, strerror(errno));
				break;
			}
		}
	}
	void ProcessConnection(int sockfd, struct sockaddr_in & peer) {
		// v2 多进程
		pid_t id = fork();
		if (id < 0) {
			close(sockfd);
			return;
		} else if (id == 0) {
			// child
			close(_listensock);
			if (fork() > 0)
			exit(0);
			InetAddr addr(peer);
			// 获取 client 地址信息
			lg.LogMessage(Info, "process connection: %s:%d\n",
			                                                addr.Ip().c_str(), addr.Port());
			// 孙子进程,孤儿进程,被系统领养,正常处理
			Service(sockfd);
			close(sockfd);
			exit(0);
		} else {
			close(sockfd);
			pid_t rid = waitpid(id, nullptr, 0);
			if (rid == id) {
				// do nothing
			}
		}
	}
	void Start() {
		_isrunning = true;
		while (_isrunning) {
			// 4. 获取连接
			struct sockaddr_in peer;
			socklen_t len = sizeof(peer);
			int sockfd = accept(_listensock, CONV( & peer), & len);
			if (sockfd < 0) {
				lg.LogMessage(Warning, "accept socket error, errno
                                                        code: % d, error string: % s\ n ", errno, strerror(errno));
				continue;
			}
			lg.LogMessage(Debug, "accept success, get n new
                                                        sockfd: % d\ n ", sockfd);
			ProcessConnection(sockfd, peer);
		}
	}
	~TcpServer() {
	}

	private:
	uint16_t _port;
	int _listensock;
	// TODO
	bool _isrunning;
};

1.创建一个套接字,给套接字设置选项,使其可以复用地址,创建一个套接字地址结构体,ip填INADDR_ANY,port由构造TcpServer时给定,然后bind将套接字绑定地址,还没结束,将该套接字设置为监听套接字,从此,该套接字就和udp套接字一样,是没有连接,这个套接字的作用就是三次握手建立连接用的

2.start是主逻辑,创建一个套接字地址结构体,这是用来接收客户端套接字ip和端口的,接下来accept会从监听套接字全连接队列里选择一个完成三次握手的连接,然后构建连接对应的通信套接字,返回其套接字文件描述符,顺带要说的是,通信套接字和监听套接字绑定的地址是一样的,但监听套接字无连接,通信套接字有连接,所以是可以通过元组区分的

3.获得通信套接字后传参到ProcessConnection,主进程也就是父进程会创建子进程,然后父进程关闭通信套接字的文件描述符,子进程关闭监听套接字描述符,然后父进程阻塞等待回收子进程,子进程创建孙子进程,然后子进程退出释放其资源,父进程回收子进程释放子进程本身数据结构,之后父进程继续accept获取新连接,孙子进程因为子进程的退出变成了孤儿进程,孤儿进程最终由INIT进程负责回收,但在退出之前,该孙子进程将负责与客户端的通信

TCP服务器多线程版本

cpp 复制代码
const static int default_backlog = 6;
// TODO

class TcpServer : public nocopy {
	public:
	TcpServer(uint16_t port) : _port(port), _isrunning(false) {
	}
	// 都是固定套路
	void Init() {
		// 1. 创建 socket, file fd, 本质是文件
		_listensock = socket(AF_INET, SOCK_STREAM, 0);
		if (_listensock < 0) {
			lg.LogMessage(Fatal, "create socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));
			exit(Fatal);
		}
		int opt = 1;
		setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR |
		SO_REUSEPORT, &opt, sizeof(opt));
		lg.LogMessage(Debug, "create socket success,
sockfd: %d\n", _listensock);
		// 2. 填充本地网络信息并 bind
		struct sockaddr_in local;
		memset(&local, 0, sizeof(local));
		local.sin_family = AF_INET;
		local.sin_port = htons(_port);
		local.sin_addr.s_addr = htonl(INADDR_ANY);
		// 2.1 bind
		if (bind(_listensock, CONV(&local), sizeof(local)) != 0) {
			lg.LogMessage(Fatal, "bind socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));
			exit(Bind_Err);
		}
		lg.LogMessage(Debug, "bind socket success, sockfd: %d\n",
		_listensock);
		// 3. 设置 socket 为监听状态,tcp 特有的
		if (listen(_listensock, default_backlog) != 0) {
			lg.LogMessage(Fatal, "listen socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));
			exit(Listen_Err);
		}
		lg.LogMessage(Debug, "listen socket success,
sockfd: %d\n", _listensock);
	}
	class ThreadData {
		public:
		ThreadData(int sockfd, struct sockaddr_in addr)
		: _sockfd(sockfd), _addr(addr) {
		}
		~ThreadData() {
		}
		public:
		int _sockfd;
		InetAddr _addr;
	}
	;
	// Tcp 连接全双工通信的.
	// 新增 static
	static void Service(ThreadData &td) {
		char buffer[1024];
		// 一直进行 IO
		while (true) {
			ssize_t n = read(td._sockfd, buffer, sizeof(buffer) -
			1);
			if (n > 0) {
				buffer[n] = 0;
				std::cout << "client say# " << buffer <<
				std::endl;
				std::string echo_string = "server echo# ";
				echo_string += buffer;
				write(td._sockfd, echo_string.c_str(),
				echo_string.size());
			} else if (n == 0) // read 如果返回值是 0,表示读到了文件结
			尾(对端关闭了连接!) {
				lg.LogMessage(Info, "client[%s:%d] quit...\n",
				td._addr.Ip().c_str(), td._addr.Port());
				break;
			} else {
				lg.LogMessage(Error, "read socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));
				break;
			}
		}
	}
	static void *threadExcute(void *args) {
		pthread_detach(pthread_self());
		ThreadData *td = static_cast<ThreadData *>(args);
		TcpServer::Service(*td);
		close(td->_sockfd);
		delete td;
		return nullptr;
	}
	void ProcessConnection(int sockfd, struct sockaddr_in &peer) {
		// v3 多线程
		InetAddr addr(peer);
		pthread_t tid;
		ThreadData *td = new ThreadData(sockfd, peer);
		pthread_create(&tid, nullptr, threadExcute, (void*)td);
	}
	void Start() {
		_isrunning = true;
		while (_isrunning) {
			// 4. 获取连接
			struct sockaddr_in peer;
			socklen_t len = sizeof(peer);
			int sockfd = accept(_listensock, CONV(&peer), &len);
			if (sockfd < 0) {
				lg.LogMessage(Warning, "accept socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));
				continue;
			}
			lg.LogMessage(Debug, "accept success, get n new
sockfd: %d\n", sockfd);

			ProcessConnection(sockfd, peer);
		}
	}

	~TcpServer() {
	}

	private:
	uint16_t _port;
	int _listensock;
	// TODO
	bool _isrunning;
};

1、2.过程和多进程的一样,咱们直接看accept出新连接后怎么办

3.accept得到通信套接字后,将通信套接字描述符和套接字地址传参到ProcessConnection,创建ThreadData结构体,因为线程执行函数只有一个参数,想要将通信套接字描述符和套接字地址都传过去就只能封装一下,然后pthread_create创建新线程,线程又叫轻量级进程,依靠时间片并发交替运行,主线程执行完ProcessConnection后就是继续去接收新连接去了,子线程执行threadExcute,先detach使该线程退出无需主线程回收,然后将封装的结构转回类型,并利用套接字描述符进行通信

TCP底层详细剖析

socket接口

前两个参数分别指定套接字的IP层协议和传输层协议,第三个参数没用,然后函数会创建对应类型套接字的所有数据结构,包括struct file,struct socket,struct sock,然后返回该套接字的描述符

bind接口

给套接字绑定地址,也就是ip和端口,传参用的是套接字地址结构体,一般填写地址族,ip,port字段就完了,这个接口服务器在创建listen套接字时会用到,而客户端不能自己绑定,不然多个连接最后五元组是一样的,没得玩

listen接口

将一个套接字声明为监听套接字,从此这个套接字会像udp套接字一样是无连接的,即使socket创建它时是用的TCP协议。它将专门用来进行三次握手,当然三次握手是靠硬件中断推动,但listen套接字绝对是其数据结构基础,第二个参数用来描述listen套接字的全连接队列的大小,linux中这个队列大小是backlog+1

connect接口

先给客户端套接字随机绑定地址(ip加端口),然后封装SYN包,交给IP层,IP层封装报头,然后根据目的ip查路由表,得到下一跳ip和发送接口,一同交给数据链路层,网卡驱动根据下一跳ip查找arp缓存得到mac地址,然后添加mac头和校验位,写进发送接口对应网卡的发送缓冲区,然后写网卡的TDT寄存器,网卡用DMA读出数据HVY转换数据接口发出去,注意,此时报文中目的ip端口和源ip端口都很直接,就是那俩套接字绑定的,但等下就不是了,假如客户端是私网,服务器是公网,那下一跳很明显是路由器lan口ip,路由器网卡接收数据写进接收缓冲区,网卡触发硬件中断,中断控制器使目标cpu陷入内核,执行中断方法,从缓冲区读数据,然后看帧类型是ip帧,那就检查mac地址和crc校验,无问题就交给IP层,IP层会用wan口ip和序列端口替换源ip和端口,并且在地址转换表里记录(源ip,源port,目的ip,目的port)和(新ip,新port,目的IP,目的port)的映射,然后查路由表,确定下一跳ip和发送网卡,交给数据链路层发出去,当这个报文到达服务器后,网卡接收然后写进缓冲区,触发硬件中断,cpu执行中断方法,网卡驱动读出数据,根据帧类型是ip帧,需要检查校验位然后交给ip层,IP层根据报头里的协议类型交给tcp层,tcp层一看是syn报文,那就查三元组,这也是为什么服务器需要比客户端提前启动的原因了,服务器已经创建好了监听套接字并且accept搁那里阻塞,服务器进程在accept的全连接队列上阻塞,然后我们接着聊这次网卡的硬件中断,tcp层查三元组(协议,目的ip,目的端口)找到了监听套接字,那还说啥,在监听套接字的半连接队列上创建struct request_sock,然后构建ack+syn捎带应答tcp报文,交给ip层,IP层填充ip报头,这个响应报文的四元组其实就是发送过来时ip和端口将''源''和''目的''反过来填就是了,从这里我们也可以知道,服务器这边的通信套接字的五元组其源ip和源port其实是广域网路由器的,并不是客户端主机的,而客户端主机那里的则是双方主机的(我的心里有你,而你只看见了中间的那个,就这样记),接着聊,紧接着交给数据链路层发送出去,路由器收到以后,到了ip层,根据地址转换表里的记录,将报文中的目的ip和端口改了,然后再根据新目的ip查路由表,后面一系列不再重复,最后交给客户端主机,客户端主机同样网卡接收,硬件中断处理,构建最后的ack然后重新发回去,发回去ack后客户端的connect就结束了,接下来就是开始发消息,服务器收到ack后,在tcp层根据三元组找到监听套接字,再将半连接队列中的struct request_sock给升级成全连接队列里的struct sock,唤醒全连接队列等待队列上的进程,服务器进程状态被设置为R,并且被切换到运行队列里,服务器继续执行accept,将全连接队列中的struct sock给创建对应的struct socket和struct file,然后返回套接字描述符,至此服务器accept也执行完毕,开始创建新进程或新线程和客户端,并通过通信套接字进程通信了,这样父进程或主线程只负责创建新连接,而不参与与客户端的通信,这也是我为什么说监听套接字是无连接的(就像一个海王,谁都可以来,那它谁也不爱)

write和read接口

还记得吗,udp套接字无连接是三元组,那发消息和接收消息都不知道对方是谁,所以用的是sendto和recvfrom,需要加上套接字地址结构体,而我们tcp套接字,除了listen套接字是无连接的,其他的都是成对的五元组通信套接字,可以直接用write和read来发送和接受消息,因为有链接,所以知道对方是谁

close接口

客户端这边键盘按下ctrl+c,那键盘触发硬件中断,cpu陷入内核,经一系列操作后终端模拟器收到字符,将其显示在终端上,然后write写进主设备,终端驱动给客户端设置SIGINT信号,然后唤醒客户端,客户端接下来处理时钟中断或软中断(write)等结束后,会处理信号,检查struct thread_info里的标志发现被设置了TIF_SIGPEDING,于是调用do_signal,从头开始遍历pending表,找到第一个设置了pending有没有block的信号,这里肯定是SIGINT了,那于是给cpu设置好新寄存器,然后切换到用户态,执行信号处理函数,具体就是,pcb中信号码设置为2,退出码是0不管,进程状态改为Z,从运行队列中除去,释放文件描述符表等资源,给父进程shell设置SIGCHLD信号然后唤醒wait的父进程,父进程根据客户端pcb中的退出信息构建好返回status,然后释放客户端pcb本身空间,这都没有问题,但我要说的是,在客户端文件描述符表中每个文件描述符都被close,其中的的通信套接字在调close时,会向服务器发送fin包,然后释放struct file和struct socket,只剩struct sock被OS管理,它还不能释放,因为要完成四次挥手,我们以多进程的服务器来看,网卡收到消息后写进网卡接收缓冲区,然后触发硬件中断往上解包,当收到fin包解包到传输层后,一看标志位是fin包,那就根据五元组找到套接字,然后将套接字的连接状态改成CLOSE_WAIT,唤醒接收缓冲区等待队列上的服务器进程,组装ack包响应给客户端,然后该次中断就结束了,至于被唤醒的服务器进程,继续执行read,缓冲区数据是0,然后看套接字状态,是CLOSE_WAIT,那read就返回0,表示对面不会再发数据了,read返回值是零,我们再看下面的逻辑,close通信套接字,那于是自个套接字状态改成LAST_ACK,并给客户端发送fin包,然后释放struct file和struct socket,struct sock由OS管理完成剩下四次挥手,然后服务器进程exit(0),那就是执行atexit()注册的处理函数,然后把所有struct FILE的用户级缓冲区都进行刷新,也就是调用对应系统调用,最后执行_exit(),设置pcb中退出码为0,信号码不管,状态设S,移除运行队列,清除资源,给父进程设SIGCHLD,这里服务器进程是孙子进程,也就是说父进程是INIT进程,然后INIT进程通过时钟中断定时wait僵尸进程,释放其资源。客户端收到ack,触发硬件中断,解包到tcp层,根据五元组找到套接字,将套接字状态改成TIME_WAIT,然后发送ack包给服务器套接字,服务器网卡接口收到消息后触发硬件中断,往上解包到tcp层,根据五元组找到套接字,然后直接把struct sock释放,至此,服务器端通信套接字彻底释放,而客户端套接字在两分钟内如果没有收到FIN包,那说明ack包已经成功被服务器收到了,因此服务器没有超时重传fin包,这时客户端套接字的struct sock也释放了,非常完美。由此引入两个问题,一个是TIME_WAIT状态是要求在两分钟内不再受到fin包则认为对方收到ack包,假如在此两分钟内收到了fin包,那就是服务器没收到ack所以超时重传了,这时服务器端就刷新时间,并重新发送ack包。TIME_WAIT作为主动断开连接的一方会进入的一个状态,会导致这段时间会占用该地址端口,假如是客户端那影响不大,因为客户端每次是connect随机绑定地址,而服务器则会因为旧套接字占用地址而重启服务器创建监听套接字后会bind失败,怎么解决呢?那就是给套接字设置SO_REUSEADDR,这样当一个地址被TIME_WAIT占据时,就允许一个且只允许一个活跃套接字bind这个地址,这就是端口复用,所以这就是为什么多进程服务器监听套接字要设置端口复用

read和recvfrom的返回值

read和recvfrom的返回值逻辑相同,如果大于零那就是读到的数据字节数,如果是-1那就是读数据出错,如果等于0那就是连接已断开(tcp才会出现这个返回值,udp是不会出现的)

具体逻辑:udp的话如果有数据就读数据,如果没数据就阻塞等待;tcp的话如果有数据就读数据,如果没数据就检查连接状态,如果是ESTABLISHED那就阻塞等待,如果是CLOSE_WAIT那就返回0(对端主动关闭连接,也只能是对方主动关闭,如果是你主动关闭,那后面还搁这读?)

端口复用

1.显式 bind vs 隐式绑定​

​类型​ ​定义​ ​示例​
​显式 bind 通过 bind() 系统调用明确绑定地址和端口 bind(sockfd, &addr, sizeof(addr))
​隐式绑定​ 由内核自动关联地址 accept() 返回的通信套接字复用监听套接字的地址

**2. SO_REUSEADDR**​​

  • ​允许绑定处于 TIME_WAIT 状态的地址​
  • ​不允许多个活跃套接字同时绑定同一地址​
  • 主要解决服务器重启问题

边界条件验证​

cpp 复制代码
// 场景1:前一个套接字处于 TIME_WAIT
setsockopt(sock1, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sock1, &addr, sizeof(addr));  // 成功(即使端口在 TIME_WAIT)

// 场景2:前一个套接字仍活跃(未关闭)
setsockopt(sock2, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sock2, &addr, sizeof(addr));  // 失败(errno=EADDRINUSE)

**3. SO_REUSEPORT**​​

  • ​允许多个套接字同时显式绑定同一地址​IP:PORT)。
  • ​隐含 SO_REUSEADDR 的功能​
  • 所有套接字都需要设置 SO_REUSEPORT