第一次网络编程的总结

前言

为了做操作系统课设学了几天socket,踩了一大堆工程上和C++上的坑,痛苦无比,于是在这分享下我拿衣物的经验。

Socket编程

Unix philosophy中有如此脍炙人口的一条,“一切皆文件”,Socket就是一个很好的例子。
比如说大部分对于文件描述符的函数同样可以作用在Socket上:
如果想从文件中读取数据,先得打开一个文件描述符,然后read(),想写入就write();对于socket,也先得打开一个链接,想读取对方发送的信息就read(),想发送信息就write()(虽然更常用的是send()/recv())。
对我而言,操作文件和操作socket差不多,都是打开和读写,只不过文件由文件系统管理,而socket由操作系统的网络协议栈负责。

在UNP中有个很经典的例程,回射程序,我会借着这代码讲一下我对Socket编程的理解

服务端


int main(int argc, char **argv)
{
	int					listenfd, connfd;
	pid_t				childpid;
	socklen_t			clilen;
	struct sockaddr_in	cliaddr, servaddr;

	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family      = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port        = htons(SERV_PORT);

	Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

	Listen(listenfd, LISTENQ);

	for ( ; ; ) {
		clilen = sizeof(cliaddr);
		connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);

		if ( (childpid = Fork()) == 0) {	/* child process */
			Close(listenfd);	/* close listening socket */
			str_echo(connfd);	/* process the request */
			exit(0);
		}
		Close(connfd);			/* parent closes connected socket */
	}
}

从头往下来看,一开始定义了两个int作为Socket的描述符,一个用来监听,一个用来发送信息。
随后定义了两个结构体sockaddr_in,sockaddr_in是用来存放socket的网络信息的,里面包含了socket的端口,地址,网络协议类型。
接着用int socket(int domain, int type, int protocol);函数建立了一个socket的描述符,用来监听。 ps:Socket()函数是unp自己封装的(基本就是改了个名
函数的第一个参数domin用于选择网络协议,此处的AF_INET代表ipv4,第二个参数type是数据传输协议,此处SOCK_STREAM是TCP协议,而第三个参数通常与第二的参数配合,此处为0。
bzero是把内存清0的方法,等同于memset。
接下来 的三联赋值分别是:网络协议,网络地址和端口。
htonl()用来把一个ulong类型的数据从主机序转换到网络序,此处是把一个ipv4地址转换到了网络序。
而htons()是把ushort从主机序转换到网络序,此处是把端口号转换到了网络序。

<arpa/inet.h>

这里又不得不提一下数据的字节顺序,一个是“大端序”,一个是“小端序”。
低字节存放在高地址上就是大端序,低字节存放在低地址上就是小端序。
大端序其实就是符合人类阅读习惯的顺序(除阿拉伯人
数字是左边最高位右边最小位,就是高字节在低地址不是?

一般人基本对机器用的是什么字节序无感,但是网络传输数据采用的是大端序,所以引入这俩函数来进行字节序转换。

接下来是调用了int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);来把socket和地址绑定。

#include <sys/socket.h>

这里有个奇怪的地方,为什么第二个参数类型是sockaddr,第三个参数是addrlen?为什么还需要地址的长度?因为对于不同的网络协议这地址是不同的,比如对于IPV6和IPV4的地址就大不一样,所以需要不同的表示方法,而c语言并没有面向对象这种东西,所以只能以此方法传入地址。

通常,bind函数只有服务端需要调用,用来将其与某个端口绑定,这样客户端就有固定的端口可以连接(当然也可以与某个地址绑定,代表只接受特定地址的链接。

此处的地址被设为了INADDR_ANY,这代表所有连接来者不拒。

接下来调用int listen(int sockfd, int backlog);来监听这个socket,backlog代表连接队列最大数量(这东西含义还很复杂,建议差一下看看

#include <sys/socket.h>

之后调用int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);函数等待进入的链接。accept会把当前进程阻塞,直到有连接进入,那么会返回连接的socketFd和地址。

#include <sys/socket.h>

接下来么,就是接受到了请求就fork()一下,然后子进程调用ssize_t recv(int sockfd, void *buf, size_t len, int flags);接受服务端的信息,这个函数会阻塞线程直到有信息被收到。

#include <sys/socket.h>

然后调用ssize_t send(int sockfd, const void *buf, size_t len, int flags);来发送信息。

#include <sys/socket.h>

至此大致就是服务端的流程了

客户端

int
main(int argc, char **argv)
{
	int					sockfd;
	struct sockaddr_in	servaddr;

	if (argc != 2)
		err_quit("usage: tcpcli <IPaddress>");

	sockfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(SERV_PORT);
	Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

	Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

	str_cli(stdin, sockfd);		/* do it all */

	exit(0);
}

这里相比之前多了俩函数(也少了三,accept,bind,listen客户端用不到)
int inet_pton(int af, const char *src, void *dst);,这个函数用来把点分的ip地址转化为数字的ip地址(也就是在sockaddr_in里存着的),此处src就是一个类似"192.168.1.1"的ip形式。第一个参数制定了网络协议,代表其实ipv6和ipv4通用的。

#include <arpa/inet.h>

int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);,这个函数负责打开一个到目标地址的socket链接。

#include <arpa/inet.h>

错误处理

需要注意以上所有特别标出的函数其返回值都必须大于等于0,小于0代表有错误出现。Unix中C的错误处理是通过一个变量errno来表示的。
如果错误出现errno会被赋值,然后可以通过strerror(errno)来获得对错误的描述的const char*。
(然如果还有返回值代表能救,不能救的直接发SIGABRT了
对于以上所有会产生阻塞的函数要小心,因为这些函数属于慢系统调用,是会被其他的信号中断的,这时候返回值为-1,errno被设置为EINTR,一般只需要再回到上面执行一次就行了.....

我遇到的问题

1.线程同步工具

锁和锁管理器

C++11在引入线程库的同时也引入了互斥锁std::mutex
std::mutex提供了两个方法,std::mutex::lock()std::mutex::unlock()
所以咱可以用lock和unlock来进行互斥



戳啦,C++11同时引入了锁管理器std::lock_guard<mutex_type>
std::lock_guard用RAII来管理上锁和释放,为了防止死锁应该这么写

void someFunc(){
	std::lock_guard<std::mutex> lock(myMutex);
	......   
	DoAnyThingHere
}   

std::lock_guard会在初始化时进行上锁,会在函数退出时进行解锁。
类似的,还有std::unique_lock,相对于std::lock_guard更加灵活,大概就是能把锁的控制权转交给程序员,我就不再多提了。


一把std::mutex不能在同一个线程内上两次,所以并不能写出这样的代码

void someFunc(){
	myMutex.lock();
	......  
	myMutex.lock();
	......
}   

如果你这么干了,恭喜你解锁了新UB,我的环境下会爆出一个system-error。

If lock is called by a thread that already owns the mutex, the behavior is undefined: for example, the program may deadlock. An implementation that can detect the invalid usage is encouraged to throw a std::system_error with error condition resource_deadlock_would_occur instead of deadlocking.

如果真有这种需求,需要用到std::recursive_mutex,这个可以进行多次上锁,然而 最好不要用它


对于一些特殊情况,比如同时需要读取和写入的情景,可以用到读写锁,std::shared_mutex,这是C++17的特性了。他可以做到:

  1. 共享 - 多个线程能共享同一互斥的所有权;
  2. 独占性 - 仅一个线程能占有互斥。

当然,在读取的时候共享,在写入的时候独占。默认的是写优先,谁不喜欢最新的数据呢?

在读取的情况下std::shared_mutex一般配合std::shared_lock来使用

void myFunc(){
	std::shared_lock<std::shared_mutex> lock(shared_mutex);
	//Read something.....
	........
}

在写入的情况下呢,就需要用到std::lock_guard或者std::unique进行锁管理

void myFunc(){
	std::unique_lock<std::shared_mutex> lock(shared_mutex);
	//Read something.....
	........
}

当然也可以裸锁.....不要和自己过不去

条件变量

C++11中引入了std::condition_variable,其作用就是让一个线程在没有满足运行条件时可以挂起,并且可以在恰当的时机被另一个线程唤醒。
举个最简单的“生产者消费者”的代码例子:

std::mutex _mutex;
std::condition_variable cvC;
std::condition_variable cvP;
int products = 0;
int max = 255;
void consumer(int i) {
  while (1) {
    std::unique_lock<std::mutex> lock(_mutex);
    if (products > 0) {
      products--;
      std::printf("Consumer%d : %d\n",i, products);
      std::this_thread::sleep_for(
          std::chrono::milliseconds(350 * (std::rand()) + 1) / RAND_MAX);
    } else {
      cvP.notify_all();
      cvC.wait(lock);
    }
  }
}

void producer(int i) {
  while (1) {
    std::unique_lock<std::mutex> lock(_mutex);
    if (products < max) {
      products++;
      std::printf("Producer%d : %d\n",i, products);
      std::this_thread::sleep_for(
          std::chrono::milliseconds(350 * (std::rand()) + 1) / RAND_MAX);
    } else {
      cvC.notify_all();
      cvP.wait(lock);
    }
  }
}

int main() {
  std::thread conThread1(consumer,1);
  std::thread conThread2(consumer,2);
  std::thread conThread3(consumer,3);
  std::thread proThread1(producer,1);
  std::thread proThread2(producer,1);
  std::thread proThread3(producer,1);
  conThread1.join();
  conThread2.join();
  conThread3.join();
  proThread1.join();
  proThread2.join();
  proThread3.join();
}

首先,这是操作系统课的范畴,我就略提一下.....
生产者生产商品,消费者消费商品,当商品不足的时候,消费者会通知生产者生产,产品富足的时候生产者会通知消费者消费。
在这里,std::condition_variable起到一个挂起当前线程\唤醒其他线程的作用。
其有几个常用的成员函数:
wait(std::unique_lock),这个函数会释放参数中的那把锁,然后挂起当前线程,等待被唤醒,然后重新获得锁,而如果无法获得锁,则会继续挂起当前线程,直到取得锁。
notify_all()notify_one(),就是字面意思,会唤醒正在等待的条件变量,也就是block在wait处的条件变量。

对于wait(lock)需要注意:

  1. 如果执行函数的时候,lock中的mutex不是被当前线程上锁会导致UB(lock - an object of type std::unique_lock< std::mutex>, which must be locked by the current thread)

  2. 如果lock中的mutex和其他所有线程中相同的并且正在等待的条件变量持有的那一把锁不一样 会导致UB

2.setjmp/longjmp 和getcontext/setcontext之谜

在我写的程序里有这样一个需求,就是程序收到SIGQUIT后会跳转到输入命令模式,来选择聊天的对象和发送消息。
那么代码大概就是

void sigquitHandler(int){
    gotoCommandLine;
}

..........     
signal(SIGQUIT,sigquitHandler);  

这种情况下,按下ctrl+\会中断当前程序的运行,然后执行sigquitHandler()
由于程序代码是顺序执行的,所以当程序执行完了sigquitHandler()后就会跳回原来的运行流。然而我要寻找一个“一劳永逸”跳出程序执行流的方法。
goto只能进行同函数内的跳转,所以我不得不寻找其他的黑科技。

我把目光放到了c语言的两个宏上,setjmp/longjmp,其运行方式让任何见到的人费解。
两个函数的用法是这样的:首先需要一个jmp_buf env变量,然后用setjmp(env)来生成一个跳转点,最后用longjmp(env,1)来跳转到跳转点。
例子如下

#include <setjmp.h>
jmp_buf env;
int main(){
    if(setjmp(env)){
        printf("I am back.\n");
        return 0;
    }
    printf("I am Here.\n");
    longjmp(env,1);
}   

执行结果如下:

➜  feiqi3 ./test.out                
I am Here.
I am back.

可以发现在一开始程序从if上“过去了”,然后直接打印了"I am Here.",神奇的地方来了,程序又回到了if里,然后打印了“I am back.”。
由此可以发现

  1. longjmp(env,1)可以让程序的执行流回到setjmp(env)处
  2. setjmp的返回值在第一次执行的时候为0,而跳转过去后会变成1

需要注意的是:

  1. setjmp(env)必须要在longjmp之前执行,目的是初始化jmp_buf,保存当前上下文
  2. longjmp的第二个参数可以影响setjmp第二次的返回值
  3. longjmp只能进行一次跳转,第一次跳转后jmp_buf就会被设置为valid,然后无法进行第二次跳转
  4. 我查了很久也没发现能重置jmp_buf的方法,所以大概真的只能跳转一次 (memset没试过,感觉太歪门邪道了
  5. 在信号处理函数中longjmp是未定义行为

If the calling process leaves the signal handler using longjmp(2), the original context cannot be restored, and the result of future calls to getcontext(2) are unpredictable.

ucontext

在UNIX系环境下,有个头文件<ucontext.h>
其中定义了一个结构体:ucontext_t,这个结构体足足有1kb那么大,如果点进去可以看到一个庞大的定义

typedef struct ucontext_t
  {
    unsigned long int __ctx(uc_flags);
    struct ucontext_t *uc_link;
    stack_t uc_stack;         //指明了context栈的大小和栈指针
    mcontext_t uc_mcontext;   //用来描述处理器的上下文-寄存器相关
    sigset_t uc_sigmask;      
    struct _libc_fpstate __fpregs_mem;
    __extension__ unsigned long long int __ssp[4];
  } ucontext_t;

jmp_buf不同的是ucontext_t完完整整地保存了一个完整的上下文,因此它能做的事情更多(就比如大多数Unix only的协程库都是以ucontext.h为核心的..
这个头文件包含了四个函数

/* Get user context and store it in variable pointed to by UCP.  */
extern int getcontext (ucontext_t *__ucp) __THROWNL;

/* Set user context from information of variable pointed to by UCP.  */
extern int setcontext (const ucontext_t *__ucp) __THROWNL;

extern int swapcontext (ucontext_t *__restrict __oucp,
			const ucontext_t *__restrict __ucp)
  __THROWNL __INDIRECT_RETURN;

extern void makecontext (ucontext_t *__ucp, void (*__func) (void),
			 int __argc, ...) __THROW;

鉴于我简陋的需求,我只用到了int getcontext (ucontext_t *__ucp)``int setcontext (const ucontext_t *__ucp),其用法和setjmp/longjmp大差不差,一个获取当前上下文,一个切换到目标上下文。
而setcontext可以进行多次跳转。

小小的问题

到目前为止都十分的休闲,只是有一点点的小问题,就是由于setcontext会强行跳出控制流,而不是正常退出,所以当前函数的栈并不会释放。
比如下面的代码

int i = 0;
std::mutex _mutex;
ucontext_t con;

int func(){
    std::lock_guard<std::mutex> lock(_mutex);
    i = 1;
    setcontext(&con);
    return 1;
}

int main(){
    getcontext(&con);
    if(!i){
        func();
    }{
        std::thread t([](){
            _mutex.lock();
            printf("Hello mutex");
            _mutex.unlock();
        });
        t.join();
    }
    printf("helllo world!\n");
    return 0;
}

在我这里就直接死锁了。
原因是std::lock_guard<std::mutex> lock没有正确被构析,所以千万要注意。

其他遇到的问题

遇到过一个很神奇的事情,就是程序被map.clear()阻塞了,这个map里存的是一个std::mutex和std::condition_variable。

以下是沙皮错误列表

  • std::shared_ptr不论何时都应该以值语义传递
  • std::thread必须要执行join或者detach方法,不然子线程会std::terminate
  • 如果放进thread里的函数是lambda那得小心了,如果lambda捕获了一堆外部的变量而外部变量被构析了.......
  • 虽然std::mutex没有复制构造函数移动构造函数,但仍然可以把它塞进shared_ptr以此放入stl容器中
  • 几乎所有的stl容器只能保证读取时线程安全,所以最好在访问前上个读写锁...

沙皮错误太多了,去看陈硕大大的书了.....

睡觉去了睡觉去了




END