挑战一篇文章搞定C语言核心语法。
如果你点进这篇文章说明你对C语言是有兴趣的,或者学过一些C语言。不知道大家在学习C语言的过程中有没有这样的经历。
学了一阵,能写一些小程序了。
工作中用不到C语言,加了几天班,回过头来想再捡起来。发现,不知道怎么下手了。比如,要使用printf这个函数,到底应该引用哪个头文件?怎么声明一个数组?怎么定义一个结构体?
于是硬着头皮又从头到尾把C语言的语法知识再看一遍,如此往复...
上面至少是我自己学习C语言的过程中经常出现的状态,也不知道如此循环往复了多少次以后,有一天突然开悟,可以上手写一些项目了。但这个过程明显是低效的。后面我写了很多笔记,但由于平时用C语言多数是单片机的程序,没有比较好的通用性。所以,我又基于Linux环境重新写了一个小程序。
下面就是基于Linux的一段小程序,你可以先看一遍,看不懂也没关系,后面会详细说明。
struct worker_thread_context {
int fd;
pthread_t thread_id;
pthread_mutex_t mutex;
pthread_cond_t cond;
};
int create_tcp_socket(int port);
size_t read_line(int fd, char *buf, size_t size);
void write_response(int fd);
void *worker(void *arg);
void *accepter_thread(void *arg);
int main(int argc, char *argv[]) {
if (argc != 2) {
perror("usage: ./server <port>");
exit(1);
}
int sock_fd = create_tcp_socket(atoi(argv[1]));
printf("server started on port %sn", argv[1]);
accepter_thread((void *)&sock_fd);
}
void *worker(void *arg) {
struct worker_thread_context *ctx = (struct worker_thread_context *) arg;
while (1) {
pthread_mutex_lock(&ctx->mutex);
pthread_cond_wait(&ctx->cond, &ctx->mutex);
if (ctx->fd == -1) {
perror("invalid fd");
break;
}
printf("THREAD-ID: %ld; FD: %dn", ctx->thread_id, ctx->fd);
char buf[1024];
read_line(ctx->fd, buf, sizeof(buf));
printf("%s", buf);
memset(buf, 0, sizeof(buf));
read_line(ctx->fd, buf, sizeof(buf));
printf("%s", buf);
memset(buf, 0, sizeof(buf));
read_line(ctx->fd, buf, sizeof(buf));
printf("%s", buf);
write_response(ctx->fd);
pthread_mutex_unlock(&ctx->mutex);
}
}
void *accepter_thread(void *arg) {
int fd = *(int *)arg;
struct worker_thread_context *ctx = malloc(WORKER_POOL_SIZE * sizeof(struct worker_thread_context));
for (int i = 0; i < WORKER_POOL_SIZE; i++) {
pthread_mutex_init(&ctx[i].mutex, NULL);
pthread_cond_init(&ctx[i].cond, NULL);
ctx[i].fd = -1;
pthread_create(&ctx[i].thread_id, NULL, worker, (void *)&ctx[i]);
}
struct sockaddr_in client_addr;
int idx = 0;
while(1) {
bzero(&client_addr, sizeof(client_addr));
socklen_t len = sizeof(client_addr);
int conn_fd = accept(fd, (struct sockaddr *)&client_addr, &len);
if (conn_fd < 0) {
perror("accept error");
break;
}
if (idx + 1 == WORKER_POOL_SIZE) {
idx = 0;
}
pthread_mutex_lock(&ctx[idx].mutex);
ctx[idx].fd = conn_fd;
pthread_mutex_unlock(&ctx[idx].mutex);
pthread_cond_signal(&ctx[idx].cond);
idx++;
char buf[1024];
read_line(conn_fd, buf, sizeof(buf));
printf(buf);
memset(buf, 0, sizeof(buf));
read_line(conn_fd, buf, sizeof(buf));
printf(buf);
memset(buf, 0, sizeof(buf));
read_line(conn_fd, buf, sizeof(buf));
printf(buf);
write_response(conn_fd);
}
}
int create_tcp_socket(int port) {
int sock_fd;
struct sockaddr_in serv_addr;
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(port);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(sock_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(sock_fd, 1024);
return sock_fd;
}
void write_response(int fd) {
send(fd, "HTTP/1.1 200 OKrn", strlen("HTTP/1.1 200 OKrn"), 0);
send(fd, "Host: 127.0.0.1:3000rn", strlen("Host: 127.0.0.1:3000rn"), 0);
send(fd, "Content-Type: text/html; charset=UTF-8rn", strlen("Content-Type: text/html; charset=UTF-8rn"), 0);
send(fd, "rn", strlen("rn"), 0);
char *body = "<html><head><title>hello</title></head><body><h1>This is My HTTP Server</h1></body></html>";
send(fd, body, strlen(body), 0);
close(fd);
}
size_t read_line(int fd, char *buf, size_t size) {
size_t i = 0;
ssize_t n;
char c = ' ';
while((i < size) && (c != 'n')) {
n = recv(fd, &c, 1, 0);
if (n > 0) {
if (c == 'r') {
n = recv(fd, &c, 1, MSG_PEEK);
if ((n > 0) && (c == 'n')) {
recv(fd, &c, 1, 0);
} else {
c = 'n';
}
}
buf[i] = c;
i++;
} else {
c = 'n';
}
}
buf[i] = ' ';
return i;
}
这段代码,虽然只有短短100多行。但是它实现了一个迷你的HTTP服务,并且还实现了一个简单的线程池。
你可以使用下面的命令在Linux环境下执行,得到一个wechat-demo二进制文件。
benggee@benggee:~$ gcc main.c -o wechat-demo -lpthread
benggee@benggee:~$ ./wechat-demo 3000
server started on port 3000
接着,我们在浏览器输入{host}:3000就可以访问了,如下:
可以看到服务已经运行起来了,接着我们看一下程序的日志
演示完了,我们接下来详细分析一下这段代码。
程序的入口main函数
int main(int argc, char *argv[]) {
if (argc != 2) {
perror("usage: ./server <port>");
exit(1);
}
int sock_fd = create_tcp_socket(atoi(argv[1]));
printf("server started on port %sn", argv[1]);
accepter_thread((void *)&sock_fd);
}
一般我们认为main函数是入口函数,程序从main函数开始执行 。当然,程序真正被执行的入口函数可能和我们想象的不太一样,这个后面有专门的文章解释。
main函数接收两个参数,int argc 和 char *argv[],前者是程序传入参数的个数,后者是参数的字符数组。上面的示例中,程序是第一个参数,端口号是第二个参数。
main函数返回一个int类型,一般约定0表示正常的返回。在我们的代码中,在程序的最后并没有return,这是由于最后这个return编译器会自动为我们加上,所以可以不写。
变量声明
int sock_fd = create_tcp_socket(atoi(argv[1]));
...
int fd = *(int *)arg;
...
int sock_fd;
...
struct worker_thread_context *ctx = malloc(WORKER_POOL_SIZE * sizeof(struct worker_thread_context));
...
struct sockaddr_in serv_addr;
...
size_t i = 0;
ssize_t n;
char c = ' ';
...
char buf[1024];
...
char *body = "<html><head><title>hello</title></head><body><h1>This is My HTTP Server</h1></body></html>";
通过对上面这些变量的观察,我们发现定义变量大体可以分为两种方式,第一种,只声明,比如int sock_fd;就是只声明了一个sock_fd但没有给它一赋值。第二种,声明并赋值,比如int fd = *(int *)arg; 声明变量的同时给其赋值。
对于int sock_fd; 这句之后如果我们直接使用sock_fd这个变量,它的值是什么呢?答案是不确定,和Golang、Java、Python这类语言不同,由于C语言中内存是程序员自己管理的。假如sock_fd分配的内存刚好在一块没有被释放的内存上,这个值是不确定的。
如果我们只是先声明,留着后面使用,这种方式是没有什么问题的。但对于有些情况这种做法可能就会有问题。
比如下面这段代码:
int *a;
printf("a: %dn", *a);
上面这段代码,可以编译通过,但在运行的时候就会得到一个段错误:Segmentation fault。这是因为没有初始化的指针可能指向任何地方。更深入的原因我们在另外的文章里再深入研究。这里你只需要记住这个小陷阱就行了。
所以,对于指针变量,我们一定要初始化,一般情况我们使用malloc或者使用另外一个指针给其赋值。但你可能说了,我只是声明一下,还不知道赋个什么值呢!也简单,我们只需要给它一个NULL就可以了,如下:
int *a = NULL;
这个小陷阱一定要注意!
上面变量的声明还有几个细节要单独拎出来讲一下。
-
字符是单引号,使用char c这种形式声明
-
字符数组有char buf[1024]和 char *body两种方式,前者声明之后可以修改。后者声明之后就不能使用body[2] = 'a'这种方式修改了,但可以重新给其赋值,比如 *body = "test"
-
size_t和ssize_t其本质也长整型,它们的原型分别是typedef unsigned long size_t和typedef long ssize_t
变量的使用
我们先看一下bzero
bzero(&serv_addr, sizeof(serv_addr));
bzero的作用是将对应这个变量的内存从开始到结束都置为0,相当于对变量进行一次初始化。
结构体成员的访问
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(port);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
上面是结构成员的访问,如果是结构指针可以使用->符号,例如:
ctx->fd = 0;
ctx->thread_fd = 0;
字符数组的特殊性
我们看下面的代码:
...
buf[i] = ' ';
return i;
在最后,我们在buf[i]赋值了一个' ',这是因为在C语言中,字符数组永远以' '结束。我们看下面几个例子:
char buf1[1024] = "abcd abcd";
char buf2[5] = "abcde";
printf("buf1:%sn", buf1); // 结果是 abcd
printf("buf2:%sn", buf2); // 结果未知
特别要注意后一种情况,假如我们没有给字符串最后一个字符给一个' ',那么后续的操作可能会出错。所以,声明字符数组都会声明实际长度+1,最后一个字符是' ',这个一定要注意!!
其它的变量操作没什么好说的。但如果你是python、node过来的,以前从来没接触过C语言,那么这里可能要习惯一下强类型的规则,比如在python中我们可以这样声明一个变量:
num = 100
这在C语言里是不合法的,C语言中变量的声明一定是 类型 + 变量名。
函数
size_t read_line(int fd, char *buf, size_t size);
int main(int argc, char *argv[])
main也是一个特殊的函数,通常来说函数由以下几部分组成:
[返回值] [函数名]([参数1], [参数1], [...]) {
[函数体]
}
在C语言中,很多情况下返回值一般是用来判断是否出错的,如果要返回参数,比如要返回一个结构体,一般都是当作参数以指针的方式传进去然后在函数中为其赋值,例如:
int get_user(int id, struct user *u) {
...
u->id = id;
u->name = "张三";
u->age = 35;
...
}
当然你也可以直接返回一个struct user的结构体指针,例如:
sturct user *get_user(int id) {
...
struct user *u = malloc(sizeof(struct user));
u->id = id;
u->name = "张三";
u->age = 35;
return u;
}
可以看到,两种方式我们都拿到了一个user,但第二种方式明显有个问题,我们怎么知道出错了呢?如果你看过nginx、redis这类开源项目的源码。你会发现,它们里面大量使用了第一种方式。所以,我们在使用C语言的时候要习惯这种写法。
从python、java、go过来的人,一定觉得奇怪,第二种方式为什么我们要使用malloc来分配一块内存呢?这个涉及到C/C++的内存管理,这里一两句说不明白,后面会有专门的文章来解释。用一句话概括就是因为在get_user里面声明的变量是在get_user这个函数的栈当中的,这个函数执行完之后里面的变量就被销毁了,而malloc可以突破这个限制将变量放在堆上。
宏
...
if (idx + 1 == WORKER_POOL_SIZE) {
idx = 0;
}
pthread_mutex_lock(&ctx[idx].mutex);
ctx[idx].fd = conn_fd;
pthread_mutex_unlock(&ctx[idx].mutex);
pthread_cond_signal(&ctx[idx].cond);
idx++;
...
上面的代码中,我们使用#define定义了一个WORKER_POOL_SIZE的宏,它的值是5(关于宏更详细的内容后面单独的文章,这里只列出其基本的使用)。
使用#ifdef 可以判断是否定义了某个宏,上面的代码中,只有我们定义了WORKER_POOL_SIZE这个宏,才会去使用线程池。
#ifdef还有一个对应的#ifndef表示没有定义某个宏,这两个宏都需要一个#endif作为结尾。当然,中间可以使用#else这样的分支结构。
流程与循环
while((i < size) && (c != 'n')) {
...
}
上面是一个while循环。表示只要()中的条件为真就一直循环下去。
当然,也有for循环,如下:
for (int i = 0; i < WORKER_POOL_SIZE; i++) {
pthread_mutex_init(&ctx[i].mutex, NULL);
pthread_cond_init(&ctx[i].cond, NULL);
ctx[i].fd = -1;
pthread_create(&ctx[i].thread_id, NULL, worker, (void *)&ctx[i]);
}
除了上面两种循环还有do{}while()循环,如下:
do {
...
} while();
其语义也很清晰,这里不做过多解释。
流程控制,除了常用的if外,C语言也支持switch,例如:
switch (a) {
case 1:
// code
break;
default:
// code
break;
}
要注意的是,switch每个case执行完需要break。对于golang和python过来的需要注意一下。
多线程
for (int i = 0; i < WORKER_POOL_SIZE; i++) {
...
pthread_create(&ctx[i].thread_id, NULL, worker, (void *)&ctx[i]);
}
使用pthread_create函数创建一个线程,这个函数的签名如下:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
返回0代表成功,返回其它一般认为是创建失败了。这里我们为了节省空间,没有对返回值作判断。为了严谨,你可以处理一下返回值。
第一个参数是pthread_t类型的指针,表示线程的ID,第二个参数表示线程的附加参数,可以对线程进行更加精细的控制,这里我们为了简单传了NULL。第三个参数是一个函数指针,是线程的主函数。也就是线程最终被操作系统调度时要执行的入口,最后一个参数是传给主函数的参数。
当然对于线程的操作,还有很多其它的函数,比如用于等待子线程结束的pthread_join,线程分离函数pthread_detach,线程取消函数pthread_cancel、退出函数pthread_exit、线程资源清理的pthread_cleanup_push和pthread_cleanup_pop等,篇幅就限,这里不深入展开。
锁mutex
在多核时代,多线程往往意味着对数据的竞争,从而产生并发问题。C语言的解决方案之一便是mutex,在pthread库中,提供了一对pthread_mutex_lock和pthread_mutex_unlock来获取锁和释放锁。
这对函数需要一个操作的对象,这个对象在pthread库中也作了定义,也就是pthread_mutex_t。我们还是来分析一下上面的代码:
pthread_mutex_lock(&ctx[idx].mutex);
ctx[idx].fd = conn_fd;
pthread_mutex_unlock(&ctx[idx].mutex);
上面的代码中,主线程通过accept获取到一个套接字,然后将这个套接字交给woker线程,在交给worker线程之前要先加锁,再给线程对应的上下文的套接字赋值。在woker线程中,执行业务逻辑之前也要先拿到锁,如下:
while (1) {
pthread_mutex_lock(&ctx->mutex);
pthread_cond_wait(&ctx->cond, &ctx->mutex);
...
pthread_mutex_unlock(&ctx->mutex);
}
这样做的语义是在worker处理完一个http请求之前,对应的套接字是不能变的。
可能你比较好奇,这里的worker线程中的
pthread_cond_wait(&ctx->cond, &ctx->mutex);
这个又是表示什么意思呢?实际上,上面worker线程中的 pthread_mutex_lock(&ctx->mutex)是可以省略的,关于这个我们接着往下看。
条件变量cond
多线程被创建出来之后,有时候它们并不是单打独斗,而是相互配合。这就要求线程之间需要有一种沟通机制。而条件变量就是线程之间实现通信的机制之一。
cond通过两个函数实现线程之间的通信,它们是pthread_cond_wait和pthread_cond_signal。字面意思很明显,一个是等待,一个是发送通知。我们还是截取上面的代码片段来看。
在主线程中
pthread_mutex_lock(&ctx[idx].mutex);
ctx[idx].fd = conn_fd;
pthread_mutex_unlock(&ctx[idx].mutex);
pthread_cond_signal(&ctx[idx].cond);
在worker线程中
pthread_mutex_lock(&ctx->mutex);
pthread_cond_wait(&ctx->cond, &ctx->mutex);
if (ctx->fd == -1) {
perror("invalid fd");
break;
}
在主线程中,当我们accept到一个套接子,并将这个套字赋值给worker线程的上下文。然后我们释放锁并调用pthread_cond_signal函数通知worker线程。然后worder线程从pthread_cond_wait中唤醒,接着开始执行下面的逻辑。
从worker线程的语义来讲,这一句似乎有点多余。我们分析一下这段程序,如果主线程一直没有客户端连进来。worker线程执行完逻辑之后,又回到前面的逻辑继续执行。细心的你可能发现了,我们在write_response函数里面已经把当前的套接字给close了,如果再对其进行写就会报错。并且会一直报错,while循环会一直执行下去。瞬间CPU就会打满,这显然不是我们期望的。
那cond是如何解决这个问题的呢?我们先要对pthread_cond_wait有一个认识,这个函数接收两个参数,第一个参数是当前这个线程的pthread_cond_t,第二个参数是一个mutex。这个函数执行的时候,首先会释放掉mutex。然后立即进入睡眠状态,并阻塞在这里,等待被唤醒。当主线程调用pthread_cond_signal的时候。立即被唤醒,并持有mutex这把锁。这里要注意,pthread_cond_wait进入睡眠释放锁是原子的,被唤醒并再次持有锁也是原子的。
搞明白了,pthread_cond_wait的原理,我们再分析一下上面的woker线程的原理。第一次进到while循环,会被阻塞。直到主线程分配了一个套接字调用pthread_cond_signal函数时,被唤醒同时持有mutex这把锁。此时主线程如果尝试将新的套接字交给worker线程,由于worker线程还没执行完,主线程将阻塞在pthread_mutex_lock,看到worker线程执行完逻辑并释放锁。
到这里,你可能看出来了,其实pthread_cond_wait上的那一句pthread_mutex_lock是可以不要的。这里只是为了逻辑上看起来完整就写上了。
当然,这里面还有很多性能问题。比如,worker线程如果处理得很慢,那主线程就一直阻塞在那里,这个你可以试着想一下怎么解决。在现代网络程序中基于事件分发的非阻塞网络程序已经成了事实上的标准了,后面会在网络编程系列文章中详细说明。
总结
当然C语言除了上面提到的部分,还有很多其它内容在本文中没有提到,比如union、enum、时间的操作、数学计算等等。内容还是比较多的,不可能做到面面俱到。学习编程语言就是这样,我们要学会忍耐学习基础知识的枯燥。如果你想更深入的理解C语言,看更多的资料是免不了的。
但根据我自己以往的经验,先把一门编程语言用起来要比一开始就从头到尾面面俱到要来得快。如果你有其它语言的基础,那就先把它当成你自己那门语言来用。先写出东西来,这样不仅学得快还记得牢。
那这篇文章想要达到的效果就是这样,希望通过一个简短可以运行的程序让你快速进入状态,希望对大家有帮助,大家加油!
发表评论