其实有了UDP的基础,TCP不管怎么说学习起来都还是比较舒服的,至少是比直接就学习TCP的感觉好。
这篇文章最多就是介绍一下起手式,如果想带业务的话和UDP那篇是完全一样的,就不进行演示了。
总的来说还是很简单的。
目录
- Echo
- 服务端起手式
- 服务端LOOP
- 客户端起手
- 客户端LOOP
- 验证
- 改进
- 方案一
- 方案二
- 验证
Echo
我们还是从最简单的不带业务的Echo开始。
服务端起手式
服务器起手式
首先要说明的一点是,TCP是面向字节流,有连接。
而UDP创建好套接字后不管连接,直接recvfrom,sendto就可以发送。
在TCP编码中会体现出有连接的特点,面向字节流会在理论中提及,代码中实践。
创建套接字
首先与UDP不同的是,UDP的第二个参数是SOCK_DGRAM
。
_listenfd = ::socket(AF_INET, SOCK_STREAM, 0);
关于这两个参数不同含义更详细的放在下图中了。
另外,这次使用listenfd接收,以前我们获得的就是sockfd,直接使用这个描述符进行收发,那么叫listenfd肯定是有特别用意。
进行bind
bind没什么好说的,仍旧是要注意填参问题。
int n = ::bind(_listenfd, (struct sockaddr *)&local, len);
进入listen状态
从这里就可以看到面向连接的痕迹了。
那么什么叫listen状态?
我们举一个小例子:
UDP就是无人售货店,不许用老板看店即可买东西。
但是TCP就是有人售货店,需要老板在才可以买东西,而TCP就是那个有人售货店,listen就是设置为老板模式,这样就可以接客了。
第一个参数就是你得到的listenfd。
第二个参数是连接队列长度,这个值我们暂时先不用管,一般默认设置为4, 8, 16即可,不需要太大。
n = ::listen(_listenfd, gbacklog);
服务端LOOP
accept
来了新的连接我们就要去accept了
换句话说就是在有人售货店中现在如果来客人了老板就需要去接客。
int sockfd = ::accept(_listenfd, (sockaddr *)&peer, &len);
参数是输出型参数,来一个连接就可以获得这个连接的基本信息。
但是要注意填参时一定要正确填参,虽然addrlen是一个输出型参数,但仍然需要正确初始化,不能不初始化。
我就犯了这个错误,导致有时错误(accept或者connect错误,connect在客户端会提到),甚至搞的我以为这是tcp特性…
但是我们注意到返回值是一个文件描述符!?那么他是什么?与listenfd有何区别?我们举一个例子进行理解:
上图可以形象的帮助理解返回值。
其中饭店 = 服务器
客户 = 新连接
揽客员 = listenfd(socket返回值)
服务员 = sockfd (accept返回值)
listenfd不直接提供服务,sockfd才直接提供服务!
所以在TCP服务端我们一般将socket返回值叫做监听套接字。
没有连接时就会陷入阻塞状态,accept失败返回-1(这里可以理解为揽客员都把路人拉到店里面,但是路人又临时有事离开了,导致拉客失败~)。
下段代码是TCP的框架代码,与UDP有很大的不同。
void Loop()
{
_isrunning = true;
while (_isrunning)
{
sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = ::accept(_listenfd, (sockaddr *)&peer, &len);
if (sockfd < 0)
{
continue;
}
// version 0 :提供长服务。
service(sockfd);
}
_isrunning = false;
}
与UDP有很大的不同:
每一次循环都对应着一个新连接,我们随后要对这个新连接进行长服务;
而UDP是不管来的是谁,都统一处理。
读写操作
我们在长服务中进行读写操作。
因为我们是面向字节流
,而管道、文件也都是流。
且Linux下一切皆文件,管道、文件都是文件,所以都是可以用文件操作read,write进行读写,所以这里也可以使用文件操作!
void service(int sockfd)
{
// 长服务
while (true)
{
char inbuffer[1024];
int n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if (n > 0)
{
inbuffer[n] = 0;
int m = write(sockfd, inbuffer, n);
if (m < 0)
{
break;
}
}
else if (n == 0)
{
break;
}
else
{
break;
}
}
::close(sockfd);
}
但是这里有一个细节要注意:read的返回值>0是表示读到的字节数(无\0),==0时表示读到文件结尾,在这里表示客户端结束。
就想吃在管道那里,写端管了,读端读出的自然都是0了。
客户端起手
socket
int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
注意客户端仍旧是用socket的返回值进行通信。
connect
同样,客户端还是不用bind,在UDP那是sendto时OS进行绑定,而在这就是connect时帮我们自动绑定。
sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
inet_pton(AF_INET, ip.c_str(), &peer.sin_addr);
peer.sin_port = htons(port);
peer.sin_family = AF_INET;
int n = ::connect(sockfd, (sockaddr *)&peer, sizeof(peer));
客户端LOOP
这里没啥好说的,就是普通的write + read。
但是要注意:由于是面向字节流,所以这里的处理是有问题的!具体如何操作请看用户自定义协议与序列化
while (true)
{
std::cout << "Please enter#";
std::string line;
getline(std::cin, line);
n = write(sockfd, line.c_str(), line.size());
if (n < 0)
{
break;
}
char inbuffer[1024];
n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if (n > 0)
{
inbuffer[n] = 0;
std::cout << inbuffer << std::endl;
}
}
完整代码链接
验证
但是当我们多开一个客户端就会发现:一个服务端只能服务一个客户端。
原因在于我们当前是串行,必须等当前客户端退出才能accept下一个客户端,并没有处理并发客户端的能力。
所以改进就来了
改进
方案一
多进程方案。
我们知道子进程是会继承父进程的代码和数据的,同样的文件描述符表也会继承,因此我们就可以利用这个特点让子进程去执行代码和数据。
首先我们fork之后就要将不需要的文件描述符关闭
对于子进程来说,是怕误操作;
对于父进程来说,是为了防止文件描述符泄露,因为进程具有独立性,当子进程继承了后,就与父进程相互不影响了。若是父进程不关闭,一直申请fd却步关闭那么就造成了资源泄露。
但是注意,由于父进程要进行等待,所以此时仍然是串行。
想要解决有两种方法
- 由于子进程结束时会发送SIGCHLD信号,于是进行信号忽略
signal(SIGCHLD, SIG_IGN)
。另外这也是最佳方案
。 - 下段代码所示:我们进行再次fork,并让子进程退出,所以此时孙子进程成为了孤儿进程,孤儿进程的父进程是OS,结束归OS管,完美的利用了OS的特性。
pid_t pid = fork();
if (pid == 0)
{
if (fork() > 0) exit(0);
// 子进程关闭不需要的fd,防止误操作。
::close(_listenfd);
service(sockfd, inetaddr);
exit(0);
}
waitpid(pid, nullptr, 0);
// 父进程关闭不需要的fd,防止内存泄漏。
::close(sockfd);
方案二
多线程
我们创建一个新线程,让新线程去执行service即可。
验证
此时同时启动两个客户端也可以完美的进行并发了。