分类 PHP 下的文章

提交凭证

场景:用户通过PC或者APP进行登录时,需要保护用户凭证避免被泄露。

说明:用户凭证必须经过加密且以POST方式提交,建议用HTTPS协议来加密通道、认证服务器。

HTML示例:用户登录功能。

<html>
    <form action="LoginServlet" method="post" onSubmit="return validate(this)">  
        用户名:<input type="text" name="name">&l;tbr>
        密  码:<input type="password" name="password"><br>  
        <input type="submit" value="登录">  
        <input type="reset" value="重置">
    </form>  
</html>
          
        

■ 错误提示+异常处理

场景:用户通过PC或者APP进行登录时,登录失败提示信息避免泄露过多信息。同时能够防止撞库等发生。

说明:安全地处理失败的身份验证,如使用“用户名或密码错误”来提示失败,防止泄露过多信息。登录入口应具有防止暴力或撞库猜解(利用已泄露的密码字典进行批量登录尝试)的措施,超过1次验证失败自动启用图灵测试,超过多次验证失败自动启用账户锁定机制限制其访问。

Java示例:用户登录失败提示,异常登录锁定等。

public class Login extends HttpServlet {  
    private static final long serialVersionUID = 1L;
    public boolean localdateLtDate(String date) throws Exception {
            SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd HH:mm:ss");
            Date dates = sdf.parse(date);
            Date now = sdf.parse(sdf.format(new Date()));
            if (now.getTime() - dates.getTime() > 24*60*60*1000) {
                return true;
            } else {
                return false;
            }
    }  
    @Override    
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 
        request.setCharacterEncoding("utf-8");  
        String username = request.getParameter("username").trim();  
        String password = request.getParameter("password").trim();  
        if (username==null || "".equals(username)) { 
            response.sendRedirect("login.html"); 
        }
        if (password==null || "".equals(password)) {
            response.sendRedirect("login.html");   
        }

        String lockFlag = "";
        String failureNum = "";
        String loginDate = "";
        String nowDate = "";
        if (service.checkLoginRecord(userName)) {
            ResultSet rs = service.getLatestLoginRecord(userName);
            if (rs != null && rs.next()) {
                    lockFlag = rs.getString("LOCK_FLAG");
                    failureNum = rs.getString("FAILURE_NUM");
                    loginDate = rs.getString("LOGIN_DATE");
            }
            if ("1".equals(lockFlag)) {
                    // lock time long than 1 day to unlock.
                    if (service.localdateLtDate(loginDate)) {
                        service.deleteLoginRecord(userName);
                        lockFlag = "";
                        failureNum = "";
                    loginDate = "";
                    }
            }
        }
        User user = new User();  
        user.setName(username); 
        user.setPassword(password);  
        List<String> info = new ArrayList<String>();  
        UserPassPort userPassPort = new UserPassPort();  
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd HH:mm:ss");   
        String nowDate = sdf.format(new Date());
        try {               
            if(UserPassPort.findValidUser(user)){  
                service.insertLoginRecord(userName, "0", "0", nowDate); 
                request.getSession().setAttribute("LoginFlag", "1");  
                info.add("登录成功!"); 
                request.setAttribute("info", info);  
                request.getSession().setMaxInactiveInterval(30*60);
                request.getRequestDispatcher("index.jsp").forward(request, response); 
            } else { 
                if ("".equals(failureNum)) {
                    service.insertLoginRecord(userName, "0", "1", nowDate);
                    info.add("用户名或密码错误!");  
                        } else {
                                if ("1".equals(lockFlag)) {
                                    response.sendRedirect("login.html");  
                        info.add("用户名或密码错误!");
                        request.setAttribute("info", info);  
                        response.sendRedirect("login.html");   
                                }
                                failt = Integer.parseInt(failureNum);
                                if (failt < 5) {
                                service.updateLoginRecord(userName, "0", String.valueOf((failt+1)), nowDate);
                        info.add("用户名或密码错误!");
                        request.setAttribute("info", info);  
                                response.sendRedirect("login.html");   
                        } else {
                            service.insertLoginRecord(userName, "1", "1", nowDate);
                        info.add("超过最大登录失败次数,已被锁定!");
                        request.setAttribute("info", info);  
                        response.sendRedirect("login.html");   
                        }
                    }
            }                         
        } catch (Exception e) {  
            response.sendRedirect("login.html");   
        }   
        }  
    @Override    
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {  
    }  
}  
          
        

二次验证

场景:重要操作需要二次校验防止跨站请求伪造攻击。

说明:在执行关键操作(如账户密码修改、资料更新、交易支付等)时,先启动图灵测试,再对用户身份进行二次验证。交易支付过程还应该形成完整的证据链,待交易数据应经过发起方数字签名。

Java示例:验证码的生成和校验实现。

A.验证码生成和确认:

public class drawCode extends HttpServlet {
    public drawCode() {
        super();
    }
    public void destroy() {
        super.destroy(); 
    }
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doPost(request, response);
    }
    public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setHeader("Pragma", "No-cache");    
        response.setHeader("Cache-Control", "no-cache"); 
        response.setDateHeader("Expires", 0);     
        int width = 60, height = 20;
        BufferedImage image = new BufferedImage(width, height,BufferedImage.TYPE_INT_RGB);
        Graphics g = image.getGraphics();
        g.setColor(new Color(200, 200, 200));
        g.fillRect(0, 0, width, height);
        Random rnd = new Random();
        int randNum = rnd.nextInt(8999) + 1000;
        String randStr = String.valueOf(randNum);
        request.getSession().setAttribute("randStr", randStr);
        g.setColor(Color.black);
        g.setFont(new Font("", Font.PLAIN, 20));
        g.drawString(randStr, 10, 17);
        for (int i = 0; i < 100; i++){
            int x = rnd.nextInt(width);
            int y = rnd.nextInt(height);
            g.drawOval(x, y, 1, 1);
        }  
        ImageIO.write(image, "JPEG", response.getOutputStream());
    }
    public void init() throws ServletException {
        // Put your code here
    }
}
          
        

验证码校验ValidateServlet.java文件:

public class ValidateServlet extends HttpServlet {
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        request.setCharacterEncoding("utf-8");      
        String code = request.getParameter("code");
        String randStr = request.getSession().getAttribute("randStr").toString();
        System.out.println(randStr);
        if (!code.equals(randStr)) {
            response.sendRedirect("/example/login.jsp?codeErro=yes");
            return ;
        } else {
            response.sendRedirect("/example/login.jsp?codeErro=no");
            return ;
        }
    }
    public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);      
    }
}
          
        

B.登录页面JSP文件:

<%@ page language="java" pageEncoding="utf-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> 
<html>
    <body>
        <script type="text/javascript">     
            function refresh() {                
                url = "/example/servlet/drawCode?" + parseInt(100*Math.random());  
                document.getElementById("imgValidate").src = url;
            }   
        </script>
        <br>
        <form name="loginForm" action="/example/servlet/ValidateServlet">
            旧密码:<input type="password" name="oldpasswd" style="height:25px"/><br/><br/>
            新密码:<input type="password" name="newpasswd" style="height:25px" /><br/><br/>
            确认新密码:<input type="password" name="confirmpasswd" style="height:25px" /><br/><br/>
            验证码:
            <input type="text" name="code" size="10" style="height:25px;vertical-align:middle">
            <img id="imgValidate" src="/example/servlet/drawCode" style="vertical-align:middle">
            <img src="/example/image/refresh.jpg" width="25" onclick="refresh()" style="vertical-align:middle"><br/><br/>
            <input type="submit" value="确定修改">
        </form>
        <c:set var="codeErro" value="${param.codeErro}"/>
        <c:if test="${codeErro=='yes'}"><p><span style="color:red;">验证码错误</span></p></c:if>
         <c:if test="${codeErro=='no'}"><p><span style="color:green;">验证码正确</span></p></c:if>
    </body>
</html>
          
        

备注:http://cighao.com/2015/08/21/validateCode-of-java/

PHP示例:验证码的生成和校验实现。

<?php 
header('Content-Type: text/html; charset=utf-8');
session_start();
require dirname(__FILE__).'/includes/global.func.php';

if ($_GET['action'] == 'verification') {
    if (!($_POST['code'] == $_SESSION['code'])) {
        _alert_back('验证码不正确!');
    }else{
        _alert_back('验证码通过!');
    }
}  
?>

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>verification code</title>
    <link rel="stylesheet" type="text/css" href="style/basic.css" />
    <script type="text/javascript" src="js/codeimg.js"></script>
</head>
<body>
    <div id="testcode">
        <form method="post" name="verification" action="verification-code.php?action=verification">
            验证码:<input type="text" name="code" class="code" />
                    <img src="codeimg.php" id="codeimg"  />
                    <input type="submit" class="submit" value="验证" />
               </form>
        </div>
</body>
</html>
          
        

■ 多因子认证

场景:在类似提交交易订单等环节需要防止跨站请求伪造攻击。

说明:高度敏感或核心的业务系统,建议使用多因子身份验证机制,如短信验证码、软硬件Token等。

Java示例:以使用软件Token为例进行说明服务器和客户端交互实现。

A.首先在服务器端Servlet中添加如下代码:

package com.example.util;  
import java.util.ArrayList;  
import javax.servlet.http.HttpSession;  

public class Token {  
    private static final String TOKEN_LIST = "tokenList";  
    public static final String TOKEN_STRING = "token";  
    private static ArrayList getTokenList(HttpSession session) {  
        Object obj = session.getAttribute(TOKEN_LIST);  
        if (obj != null) {  
            return (ArrayList) obj;  
        } else {  
            ArrayList tokenList = new ArrayList();  
            session.setAttribute(TOKEN_LIST, tokenList);  
            return tokenList;
        }
    }  
    public static String getTokenString(HttpSession session) {  
        String tokenStr = (new Long(System.currentTimeMillis()).toString());
        ArrayList tokenList = getTokenList(session);  
        tokenList.add(tokenStr);  
        session.setAttribute(TOKEN_LIST, tokenList);       
        return tokenStr;  
    }
    public static boolean isTokenStringValid(String tokenStr, HttpSession session) {  
        boolean valid = false;  
        if (session != null) {
            ArrayList tokenList = getTokenList(session);  
            if (tokenList.contains(tokenStr)) {  
                valid = true;  
                tokenList.remove(tokenStr);  
            }  
        }  
        return valid;  
    }  
}

if (Token.isTokenStringValid(request.getParameter(Token.TOKEN_STRING_NAME), request.getSession())) {  
//To Do Here.
}
          
        

B. 然后在JSP页面增加以下代码:

<%@ page import="com.example.util.Token" %>  
<form>
    <input type="hidden" name="<%=Token.TOKEN_STRING %>" value="<%=Token.getTokenString(session) %>">  
</form>
          
        

PHP示例:以使用软件Token为例进行说明服务器和客户端交互实现。

<?php
session_start();
if (!isset($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = base64_encode(openssl_random_pseudo_bytes(32));
}

if (isset($_POST['csrf_token']) && $_POST['csrf_token'] === $_SESSION['csrf_token']) {
    exit("POST data is valid.");
}
?>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" /> 
    <title>PHP CSRF Protection</title>

    <script>
    window.csrf = { csrf_token: '<?php echo $_SESSION['csrf_token']; ?>' };

    $.ajaxSetup({
        data: window.csrf
    });

    $(document).ready(function() {
        $.post('/awesome/ajax/url', { foo: 'bar' }, function(data) {
            console.log(data);
        });
    });
    </script>
</head>
<body>
    <form action="index.php" method="post" accept-charset="utf-8">
        <input type="text" name="foo" />
        <input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>" />
        <input type="submit" value="Submit" />
    </form>
</body>
</html>
          
        

编码场景

场景:不可信数据输出到前后端页面时,需要考虑可能的代码注入情况。

说明:不可信数据输出到前后端页面时,根据输出场景对其进行相关编码,如HTML实体编码、URL编码。

<html>
    <body>
        <?php
            $str = $_POST['feed'];
            echo htmlspecialchars($str, ENT_QUOTES);
        ?>
    </body>
</html>

净化场景

场景:输出到前后端页面时,如果包含敏感信息需要做脱敏等处理再输出。

说明:针对操作系统命令、SQL和LDAP查询,净化所有输出的敏感信息,如银行卡、手机号、系统信息等。

<?php
    class DesensitizedUtils {
        //【中文姓名】只显示第一个汉字,其他隐藏为2个星号,比如:李**
        function chineseName($str) {
            $resstr = substr_replace($str, '*', 1);
            return $resstr;
        }

        // 【身份证号】显示最后四位,其他隐藏。共计18位或者15位,比如:*************1234    
        function idCardNum($str) {
            $resstr = substr_replace($str, '**************', 0);
            return $resstr;
        }
    
        //【固定电话】 显示后四位,其他隐藏,比如:*******3241
        function fixedPhone($str) {
            $resstr = substr_replace($str, '****', 3, 4);
            return $resstr;
        }
    
        // 【手机号码】前三位,后四位,其他隐藏,比如:135****6810
        function mobilePhone($str) {
            $resstr = substr_replace($str, '****', 3, 4);
            return $resstr;
        }    
    
        // 【地址】只显示到地区,不显示详细地址,比如:上海徐汇区漕河泾开发区***
        function address($str) {
            $resstr = substr_replace($str, '****', -4);
            return $resstr;
        }
    
        // 【电子邮箱】 邮箱前缀仅显示第一个字母,前缀其他隐藏,用星号代替,@及后面的地址显示,比如:d**@126.com
        function email($str) {
            $resstr = substr_replace($str, '****' , 1);
            return $resstr;
        }
    
        // 【银行卡号】前六位,后四位,其他用星号隐藏每位1个星号,比如:622261******1234
        function bankCard($str) {
            $resstr = substr_replace($str, '******', 6);
            return $resstr;
        }
    
        // 【密码】密码的全部字符都用*代替,比如:******
        function password($str) {
            $resstr = substr_replace($str, '*', 0);
            return $resstr;
        }
    }

在我们日常的编码过程中,输入校验可以说是我们业务安全的第一道防线,我们需要对每一次的用户输入做一定的校验,也就是我们常说的,不要信任任何用户的输入。

对于用户的输入,我们需要做到如下的校验:

  • 白名单
  • 黑名单
  • 规范化
  • 合法性校验
  • 防范 SQL 注入文件校验
  • 访问控制

白名单

场景: 当可能输入的集合比较小的,可以选择使用列表选择来确保不能绕过验证。

说明: 不可信数据可设定白名单校验的,应接受所有和白名单匹配的数据,并阻止其他数据。

function isValidateCommandRoutine($command) {
    $WhiteList = array('cmd','ls','sh','env');
    if (false === empty($command)) {  
        foreach ($WhiteList as $key=>$value) {
            if ($value === $command) {
                return true;
            }
        }
        return false;
    } else {
        return false;
    }
}
function validateCommandArray($cmds) {
    if (isValidateCommandRoutine($cmds)) {
        //Process valid input and return here.
        system(EscapeShellCmd($cmds));
    }
    return;
}
       

黑名单

场景:部分可控输入数据集合时,拒绝已知恶意的输入数据。

说明:不可信数据中包含不良输入字符时,如空字节(%00)、换行符(%0d,%0a,\r,\n)、路径字符(../,..\)等,建议直接阻止该数据,若需要接受该数据,则应做不同方式的净化处理。

function isValidateString($s) { 
    $keywords = "[<>]"; 
    if (preg_match("$keywords", $s)) { 
        return false; 
    } else { 
        return true; 
    } 
}
       

规范化

场景:部分可控输入数据集合时,拒绝已知恶意的输入数据。

说明:不可信数据的净化和校验前需进行规范化,如将目录遍历(../或..\)等相对路径转化成绝对路径,URL解码等。

function fileCheck() {
    $basepath = './site/img/';
    $realBase = realpath($basepath);
    $realUserPath = realpath($basepath . $_GET['path']);
    if ($realUserPath === false || strpos($realUserPath, $realBase) !== 0) {
        return false;
    } else {
        return true;
    }
}

合法性校验

场景:当合法值的范围太广泛时,不能明确确定时,根据业务场景进行类似正则匹配。

说明:不可信数据的合法性校验包括:数据类型如字符、数字、日期等特征;数据范围;数据长度等。

function isValidateString() { 
    $patternname = "^[A-Za-z0-9_-]{4,}$"; 
    if ($_POST['name'] && preg_match("$patternname", $_POST['name'])) { 
        return true; 
    } else { 
        return false; 
    } 
}

防范SQL注入

场景:当用户数据进入后端SQL操作时,使用参数化查询来处理避免出现SQL注入。

说明:不可信数据的合法性校验包括:数据类型如字符、数字、日期等特征;数据范围;数据长度等。

<?php
    $dbns = 'mysql:dbname=testdb;host=127.0.0.1';
    $username = 'dbuser';
    $password = 'dbpasswd'
    try {
        $pdo = new PDO($dbns, $username, $password);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->exec('set names utf8'); 
    } catch (PDOException $e) {
        echo 'Connection failed: ' . $e->getMessage();
        exit;
    }
    $str = $pdo->prepare('select * from content where id=? and name=?');
    $str->execute(array('id'));
    $str->execute(array('name'));
    print_r($str->fetchAll());
?>

文件校验

场景:用户提交压缩文件,服务器需要进行解压缩存储时。

说明:不可信数据为解压缩的文件时,如果文件位于服务目录外或文件大小超过限制,应拒绝处理。

class Unzip{
    public function __construct() {
       header("content-type:text/html;charset=utf8");
    }
 
    public function unzip($src_file, $dest_dir) {
        $zip = zip_open($src_file)
        if ($zip) {
            $this->create_dirs($dest_dir);
            while ($zip_entry = zip_read($zip)) {
                if (zip_entry_filesize($zip_entry) > 0x640000) {
                    return false;
                }
                $pos_last_slash = strrpos(zip_entry_name($zip_entry), "/");
                if ($pos_last_slash !== false) {
                    $this->create_dirs($dest_dir.substr(zip_entry_name($zip_entry), 0, $pos_last_slash+1));
                }
                if (zip_entry_open($zip, $zip_entry, "r")) {
                    $file_name = $dest_dir.zip_entry_name($zip_entry);              
                    if (!is_file($file_name)) {
                        $fstream = zip_entry_read($zip_entry, zip_entry_filesize($zip_entry));
                        @file_put_contents($file_name, $fstream);
                        chmod($file_name, 0666);
                    }
                    zip_entry_close($zip_entry);
                }
            }
            zip_close($zip);
            return true;
        } else {
            return false;
        }
    }
 
    public function create_dirs($path) {
        if (!is_dir($path)) {
            $directory_path = "";
            $directories = explode("/", $path);
            array_pop($directories);
            foreach($directories as $directory) {
                $directory_path .= $directory . "/";
                if (!is_dir($directory_path)) {
                    mkdir($directory_path);
                    chmod($directory_path, 0755);
                }
            }
        }
    }
}

访问控制

场景:用户提交的数据,在处理前需要进行身份确认。

说明:不可信数据通过上述校验后,还应确认所提交的内容是否与用户的身份匹配,避免越权访问。

<?php
namespace App\Http\Controllers;
use App\User;
use App\Http\Controllers\Controller;

class UserController extends Controller
{
    public function __construct() {
        $this->middleware('auth');
    }

    public function show($id) {
        return view('user.profile', ['user' => User::findOrFail($id)]);
    }
}
?>

你好,我是一笑(Maksim),接下来我们要来聊一聊如何在业务中如何开发出“安全”的代码。

首先来讲一讲为什么会写关于安全的文章,最近真的是被安全团队搞的晕头转向,在老代码中存在大量的安全问题,不断的修修补补,起初感觉有些烦躁,感觉有些接口没有必要这么严格,直到我被“安利”了一款应用,由于涉敏就不在文章中详细说明了。

我来简单说一下那个应用的功能,通过输入手机号或者邮箱,可以将个人信息完整的查询出来,很恐怖,网络之上的确没有秘密,当我自己的信息被查出来之后,我是真的慌了,其中包括了我的身份证号、曾经的住址,用过的邮箱,甚至我怀疑,我开过几次房都能查到。

那这些信息都是怎么被泄露出去的呢?

让我们来看看曾经的新闻:

  • 2013年的时候,腾讯爆出被黑客盗取了大量的用户资料,其中包括7000多万个QQ群,12亿多个部分重复的QQ号码。
  • 2015年10月19日下午消息,乌云今日宣布发现新漏洞,此漏洞将导致 网易 163/126邮箱过亿数据泄漏,泄露信息包括用户名、密码、密码保护信息、登录IP以及用户生日等多个原始信息,影响数量总共近5亿条!
  • 酒店数据库被“黑” 3.27亿人次信息泄露

这些都是我们熟知的互联网公司或者产品,与此同时网上的开源产品,ecshop,discuzz, dedecms 曾经都存在大量的漏洞,如果站长不具备安全意识,没有及时的修复漏洞,那么我们的信息将会更容易被获取。

同时这样的问题还会影响品牌的声誉,会给品牌带来不可估量的损失,我很认同何为舟老师在极客时间《安全攻防技能 30 讲》中的一句话:我们在追求开发效率的同时,一定要把“安全”这俩字放在心上

就在2021 年,北京组织各大公司进行攻防演练,APP 等系统必须进行等保测试,这都意味着未来国家对信息安全将会越来越重视,而变成安全将是每一位程序员的必修课。

在公司内部,基本上所有上规模的公司都会有自己的安全审计部门,而我这段时间也正在被审计部门折磨着,历史遗留的系统有大量的系统,在扫描到我们事业部的时候,让我真的是苦不堪言,一天最多能给出八九个工单。

下面,我通过自己的亲身经历,以及对我司安全规范、《安全攻防技能 30 讲》,《PHP 安全之道》进行整理,剥丝抽茧,将各种安全手段和需要注意的地方,进行逐一讲解,下面是大纲:

接下里,说一下这个系列文章的整体结构其中涉及如下:

  • 输入验证
  • 输出编码
  • 身份验证
  • 短信验证码
  • 图灵验证码
  • 密码管理
  • 回话安全
  • 访问控制
  • 注入攻击
  • 敏感信息
  • CSRF 攻击
  • 文件上传安全
  • 接口安全
  • I/O 操作
  • 运行环境
  • 异常处理
  • 日志规范

做到上面这些,代码就已经算是相对安全了,但是只要代码是在不断产出,就会出现新的漏洞,我也会通过自己的学习和实践,不断去补充这个系列的文章。

参考资料

[1] 何为舟 《安全攻防技能 30 讲》安全攻防技能30讲_安全_漏洞_黑客-极客时间 (geekbang.org)

[2] 王昊天 Web安全攻防实战 https://time.geekbang.org/course/intro/100055001

[3] 栾涛 《PHP 安全之道》人民邮电出版社

这篇文章转载自好未来学而思技术团队,分享老师:李乐,本文基于Swoole-4.3.2和PHP-7.1.0版本

Swoole协程简介

Swoole4为PHP语言提供了强大的CSP协程编程模式,用户可以通过go函数创建一个协程,以达到并发执行的效果,如下面代码所示:

//Co::sleep() 是 Swoole 提供的 API,并不会阻塞当前进程,只会阻塞协程触发协程切换
go(function() {
    Co::sleep(1);
    echo "a";
});

go(function() {
    Co::sleep(2);
    echo "b";
});

echo "c";

//输出结果:cab
//程序总执行时间 2 秒

其实在Swoole4之前就实现了多协程编程模式,在协程创建、切换以及结束的时候,相应的操作php栈即可(创建、切换以及回收php栈)。

此时的协程实现无法完美的支持php语法,其根本原因在于没有保存c栈信息。(vm内部或者某些扩展提供的API是通过c函数实现的,调用这些函数时如果发生协程切换,c栈该如何处理?)

Swoole4新增了c栈的管理,在协程创建、切换以及结束的同时会伴随着c栈的创建、切换以及回收。

Swoole4协程实现方案如下图所示:

2023-03-25T15:37:46.png

其中:

  • API层是提供给用户使用的协程相关函数,比如go()函数用于创建协程;Co::yield()使得当前协程让出CPU;Co::resume()可恢复某个协程执行;
  • Swoole4协程需要同时管理c栈与php栈,Coroutine用于管理c栈,PHPCoroutine用于管理php栈;其中Coroutine(),yield(),resume()实现了c栈的创建以及换入换出;create_func(),on_yield(),on_resume()实现了php栈的创建以及换入换出;
  • Swoole4在管理c栈时,用到了boost.context库,make_fcontext()和jump_fcontext()函数均使用汇编语言编写,实现了c栈上下文的创建以及切换;
  • Swoole4对boost.context进行了简单封装,即Context层,Context(),SwapIn()以及SwapOut()对应c栈的创建以及换入换出。

深入理解C栈

函数是对代码的封装,对外暴露的只是一组指定的参数和一个可选的返回值;假设函数P调用函数Q,Q执行后返回函数P,实现该函数调用需要考虑以下三点:

  • 指令跳转:进入函数Q的时候,程序计数器必须被设置为Q的代码的起始地址;在返回时,程序计数器需要设置为P中调用Q后面那条指令的地址;
  • 数据传递:P能够向Q提供一个或多个参数,Q能够向P返回一个值;
  • 内存分配与释放:Q开始执行时,可能需要为局部变量分配内存空间,而在返回前,又需要释放这些内存空间;

大多数语言的函数调用都采用了栈结构实现,函数的调用与返回即对应的是一系列的入栈与出栈操作,我们通常称之为函数栈帧(stack frame)。示意图如下:

2023-03-25T15:38:42.png

上面提到的程序计数器即寄存器%rip,另外还有两个寄存器需要重点关注:%rbp指向栈帧底部,%rsp指向栈帧顶部。

下面将通过具体的代码事例,为读者讲解函数栈帧。c代码与汇编代码如下:

2023-03-25T15:39:14.png

2023-03-25T15:39:25.png

分析汇编代码:

  • main函数与add函数入口,首先将寄存器%rbp压入栈中用于保存其值,其次移动%rbp指向当前栈顶部(此时%rbp,%rsp都指向栈顶,开始新的函数栈帧);
  • main函数"subq $16, %rsp",是为main函数栈帧预留16个字节的内存空间;
  • 调用add函数时,第一个参数和第二个参数分别保存在寄存器%edi和%esi,返回值保存在寄存器%eax;
  • call指令用于函数调用,实现了两个功能:寄存器%rip压入栈中,跳转到新的代码位置;
  • ret指令用于函数返回,弹出栈顶内容到寄存器%rip,依次实现代码跳转;
  • leave指令等同于两条指令:movq %rsp,%rbp和popq %rbp,用于释放main函数栈帧,恢复前一个函数栈帧;
  • 注意add函数栈帧,并没有为其预留空间,寄存器%rsp和%rbp都指向栈帧底部;根本原因是add函数没有调用其他函数。

该程序的栈结构示意图如下:

2023-03-25T15:40:05.png

问题:观察上面的汇编代码,输入参数分别使用的是寄存器%edi和%esi,返回值使用的是寄存器%eax,输入输出参数不应该保存在栈上吗?寄存器比内存访问要快的多,现代处理器寄存器数目也比较多,因此倾向于将参数优先保存在寄存器。比如%rdi, %rsi, %rdx, %rcx, %r8d, %r9d 六个寄存器用于存储函数调用时的前6个参数,那么当输入参数数目超过6个时,如何处理?这些输入参数只能存储在栈上了。
(%rdi等表示64位寄存器,%edi等表示32位寄存器)

2023-03-25T15:40:26.png

Swoole C栈管理

通过学习c栈基本知识,我们知道最主要有三个寄存器:%rip程序计数器指向下一条需要执行的指令,%rbp指向函数栈帧底部,%rsp指向函数栈帧顶部。这三个寄存器可以确定一个c栈执行上下文,c栈的管理其实就是这些寄存器的管理。

第一节我们提到Swoole在管理c栈时,用到了boost.context库,其中make_fcontext()和jump_fcontext()函数均使用汇编语言编写,实现了c栈执行上下文的创建以及切换;函声明命如下:

fcontext_t make_fcontext(void *sp, size_t size, void (*fn)(intptr_t));
intptr_t jump_fcontext(fcontext_t *ofc, fcontext_t nfc, intptr_t vp, bool preserve_fpu = false);

make_fcontext函数用于创建一个执行上下文,其中参数sp指向内存最高地址处(在堆中分配一块内存作为该执行上下文的c栈),参数size为栈大小,参数fn是一个函数指针,指向该执行上下文的入口函数;代码主要逻辑如下:

2023-03-25T15:40:54.png

make_fcontext函数创建的执行上下文示意图如下(可以看到预留了若干字节用于保存上下文信息):

2023-03-25T15:42:12.png

Swoole协程实现的Context层封装了上下文的创建,创建上下文函数实现如下:

2023-03-25T15:42:27.png

可以看到c栈执行上下文是通过sw_malloc函数在堆上分配的一块内存,默认大小为2M字节;参数sp指向的是内存最高地址处;执行上下文的入口函数为Context::context_func()。

jump_fcontext函数用于切换c栈上下文:

  1. 函数会将当前上下文(寄存器)保存在当前栈顶(push),同时将%rsp寄存器内容保存在ofc地址;
  2. 函数从nfc地址处恢复%rsp寄存器内容,同时从栈顶恢复上下文信息(pop)。

代码主要逻辑如下:

2023-03-25T15:43:50.png

观察jump_fcontext函数的汇编代码,可以看到保存上下文与恢复上下文的代码基本是对称的。恢复上下文时"popq %r8"用于弹出上一次保存的程序计数器%rip的内容,然而并没有看到保存寄存器%rip的代码。这是因为调用jump_fcontext函数时,底层call指令已经将%rip入栈了。

Swoole协程实现的Context层封装了上下文的换入换出,可以在上下文swap_ctx_和ctx_之间随时换入换出,代码实现如下:

2023-03-25T15:44:28.png

上下文示意图如下所示:

2023-03-25T15:45:07.png

Swoole PHP栈管理

php代码在执行时,同样存在函数栈帧的分配与回收。php将此抽象为两个结构,php栈zend_vm_stack,与执行数据(函数栈帧)zend_execute_data。

php栈结构与c栈结构基本类似,定义如下:

struct _zend_vm_stack {
    zval *top;
    zval *end;
    zend_vm_stack_prev;
}

其中top字段指向栈顶位置,end字段指向栈底位置;prev指向上一个栈,形成链表,当栈空间不够时,可以进行扩容。php虚拟机申请栈空间时默认大小为256K,Swoole创建栈空间时默认大小为8K。

执行数据结构体,我们需要重点关注这几个字段:当前函数编译后的指令集(opline指向指令集数组中的某一个元素,虚拟机只需要遍历该数组并执行所有指令即可),函数返回值,以及调用该函数的执行数据;结构定义如下:

2023-03-25T15:46:47.png

php栈初始化函数为zend_vm_stack_init;当执行用户函数调用时,虚拟机通过函数zend_vm_stack_push_call_frame在php栈上分配新的执行数据,并执行该函数代码;函数执行完成后,释放该执行数据。代码逻辑如下:

2023-03-25T15:46:59.png

php栈帧结构示意图如下:

2023-03-25T15:47:25.png

Swoole协程实现,需要自己管理php栈,在发生协程创建以及切换时,对应的创建新的php栈,切换php栈,同时保存和恢复php栈上下文信息。这里涉及到一个很重要的结构体php_coro_task:

2023-03-25T15:47:40.png

这里列出了php_coro_task结构体的若干关键字段,这些字段用于保存和恢复php上下文信息。

协程创建时,底层通过函数PHPCoroutine::create_func实现了php栈的创建:

2023-03-25T15:53:51.png

从代码中可以看到结构php_coro_task是直接存储在php栈的底部。

当通过yield函数让出CPU时,底层会调用函数 PHPCoroutine::on_yield 切换 php 栈:

2023-03-25T15:54:19.png

Swoole协程实现

前面我们简单介绍了Swoole协程的实现方案,以及Swoole对c栈与php栈的管理,接下来将结合前面的知识,系统性的介绍Swoole协程的实现原理。

Swoole协程数据模型

话不多说,先看一张图:

2023-03-25T15:54:43.png

  • 每个协程都需要管理自己的c栈与php栈;
  • Context封装了c栈的管理操作;ctx_字段保存的是寄存器%rsp的内容(指向c栈栈顶位置);swap_ctx_字段保存的是将被换出的协程寄存器%rsp内容(即,将被换出的协程的c栈栈顶位置);SwapIn()对应协程换入操作;SwapOut()对应协程换出操作;
  • 参考jump_fcontext实现,协程在换出时,会将寄存器%rip,%rbp等暂存在c栈栈顶;协程在换入时,相应的会从栈顶恢复这些寄存器的内容;
  • Coroutine管理着协程所有内容;cid字段表示当前协程的ID;task字段指向当前协程的php_coro_task结构,该结构中保存的是当前协程的php栈信息(vm_stack_top,execute_data等);ctx字段指向的是当前协程的Context对象;origin字段指向的是另一个协程Coroutine对象;yield()和resume()对应的是协程的换出换入操作;
  • 注意到php_coro_task结构的co字段指向其对应的协程对象Coroutine;
  • Coroutine还有一些静态属性,静态属性的属于类属性,所有协程共享的;last_cid字段存储的是当前最大的协程ID,创建协程时可用于生成协程ID;current字段指向的是当前正在运行的协程Coroutine对象;on_yield和on_resume是两个函数指针,用于实现php栈的切换操作,实际指向的是方法PHPCoroutine::on_yield和PHPCoroutine::on_resume;

Swoole协程创建与切换

协程创建

Swoole创建协程可以使用go()函数,底层实现对应的是PHP_FUNCTION(swoole_coroutine_create),其函数实现如下:

2023-03-25T15:55:43.png

  • 注意Coroutine::create函数第一个参数伟create_func,该函数后续用于创建php栈,并开始协程代码的执行;
  • 可以看到PHPCoroutine::create在调用Coroutine::create创建创建协程之前,保存了当前php栈信息到php_coro_task结构中。
  • 注意主程序的php栈是虚拟机创建的,结构与上面画的协程php栈不同,主程序的php_coro_task结构并没有存储在php栈上,而是一个静态变量PHPCoroutine::main_task,从get_task方法可以看出,主程序中get_current_task()返回的是null,因此最后获得的php_coro_task结构是PHPCoroutine::main_task。

2023-03-25T15:56:09.png

  • 在Coroutine的构造函数中完成了协程对象Coroutine的创建与初始化,以及Context对象的创建与初始化(创建了c栈);run()函数执行了协程的换入,从而开始协程的运行;

2023-03-25T15:56:28.png

  • 可以看到创建协程对象Coroutine时,通过last_cid来计算当前协程的ID,同时将该协程对象加入到全局map中;代码ctx(stack_size,fn, private_data)创建并初始化了Context对象;
  • run()函数将该协程换入执行时,赋值origin为当前协程(主程序中current为null),同时设置current为当前协程对象Coroutine;调用SwapIn()函数完成协程的换入执行;最后如果协程执行完毕,则关闭并释放该协程对象Coroutine;
  • 初始化Context对象时,可以看到其构造函数Context::Context(size_tstack_size, coroutine_func_t fn, void* private_data),其中参数fn为协程入口函数(PHPCoroutine::create_func),可以看到其赋值给ontext对象的字段fn_,但是在创建c栈上下文时,其传入的入口函数为context_func;

2023-03-25T15:57:07.png

  • 函数context_func内部其实调用的就是方法PHPCoroutine::create_func;当协程执行结束时,会标记end字段为true,同时将该协程换出;

2023-03-25T15:57:28.png

问题:参数arg为什么是Context对象呢,是如何传递的呢?这就涉及到jump_fcontext汇编实现,以及jump_fcontext的调用了

2023-03-25T15:57:47.png

调用jump_fcontext函数时,第三个参数传递的是this,即当前Context对象;而函数jump_fcontext汇编实现时,将第三个参数的内容拷贝到%rdi寄存器中,当协程换入执行函数context_func时,寄存器%rdi存储的就是第一个参数,即Context对象。

方法PHPCoroutine::create_func就是创建并初始化php栈,执行协程代码;这里不做过多介绍。

问题:Coroutine的静态属性on_yield和on_resume时什么时候赋值的?

在Swoole模块初始化时,会调用函数swoole_coroutine_util_init(该函数同时声明了"Co"等短名称),该函数进一步的调用PHPCoroutine::init()方法,该方法完成了静态属性的赋值操作。

2023-03-25T15:59:00.png

协程切换

用户可以通过Co::yield()和Co::resume()实现协程的让出和恢复,Co::yield()的底层实现函数为PHP_METHOD(swoole_coroutine_util, yield),Co::resume()的底层实现函数为PHP_METHOD(swoole_coroutine_util, resume)。本节将为读者讲述协程切换的实现原理。

2023-03-25T15:59:18.png

  • 调用Co::resume()恢复某个协程之前,该协程必然已经调用Co::yield()让出CPU;因此在Co::yield()时,会将该协程对象添加到全局map中;Co::resume()时做相应校验,如果校验通过则恢复协程,并从map种删除该协程对象;
  • co->yield()实现了协程的让出操作;

    1. 设置协程状态为SW_CORO_WAITING;
    2. 回调on_yield方法,即PHPCoroutine::on_yield,保存当前协程(task代表协程)的php栈上下文,恢复另一个协程的php栈上下文(origin代表另一个协程对象);
    3. 设置当前协程对象为origin;
    4. 换出该协程;

2023-03-25T15:59:46.png

  • co->resume()实现了协程的恢复操作:

    1. 设置协程状态为SW_CORO_RUNNING;
    2. 回调on_resume方法,即PHPCoroutine::on_resume,保存当前协程(current协程)的php栈上下文,恢复另一个协程(task代表协程)的php栈上下文;
    3. 设置origin为当前协程对象,current为即将要换入的协程对象;
    4. 换入协程;

2023-03-25T16:00:41.png

Swoole协程有四种状态:初始化,运行中,等待运行,运行结束;定义如下:

2023-03-25T16:01:00.png

协程之间可以通过Coroutine对象的origin字段形成一个类似链表的结构;
Co::yield()换出当前协程时,会换入origin协程;
在A协程种调用Co::resume()恢复B协程时,会换出A协程,换入B协程,同时标记A协程为B的origin协程;

协程切换过程比较简单,这里不做过多详述。

Swoole协程调度

Swoole的协程调度是基于事件驱动的,下面将介绍socket读写事件以及sleep定时器事件触发的协程调度。

socket读写事件

Swoole的socket读写使用的成熟的IO多路复用模型:epoll/kqueue/select/poll等,并且将其封装在结构体_swReactor中,其定义如下:

2023-03-25T16:01:37.png

在调用函数PHPCoroutine::create创建协程时,会校验是否已经初始化_swReactor对象,如果没有则会调用php_swoole_reactor_init函数创建并初始化main_reactor对象;

2023-03-25T16:02:46.png

我们以epoll为例,main_reactor各回调函数如下:

2023-03-25T16:03:05.png

注意:这里注册了一个函数swoole_event_wait,在生命周期register_shutdown阶段会执行该函数,开始Swoole的事件循环,阻挡了php生命周期的结束。

类Socket封装了socket读写相关的所有操作以及数据结构,其定义如下:

2023-03-25T16:03:26.png

  • socket字段类型为swConnection,代表传输层连接;
  • reactor字段指向结构体swReactor对象,用于fd事件的注册、修改、删除以及wait;
  • 当调用recv()函数接收数据,阻塞了该协程时,read_co字段指向该协程对象Coroutine;
  • 当调用send()函数接收数据,阻塞了该协程时,write_co字段指向该协程对象Coroutine;
  • 类Socket初始化函数为Socket::init_sock:

2023-03-25T16:03:55.png

当我们调用CoroutineSocket->recv接收数据时,底层实现如下:

2023-03-25T16:04:13.png

类timeout_setter会设置socket的接收数据超时时间read_timeout为timeout。

函数socket->recv_all会循环读取数据,直到读取到指定长度的数据,或者底层返回等待标识阻塞当前协程:

2023-03-25T16:04:29.png

  • 函数首先创建timer_controller对象,设置其超时时间为read_timeout,以及超时回调函数为timer_callback;
  • while(true)死循环读取fd数据,当读取数据量等于__n时,读取操作结束,break该循环;如果读取操作swConnection_recv返回值小于0,并且错误标识为SW_WAIT,说明需要等待数据到来,此时阻塞当前协程等待数据到来(函数wait_event会换出当前协程),阻塞超时时间为read_timeout(函数timer.start()用于设置超时时间)。

2023-03-25T16:04:53.png

函数swTimer_add用于添加一个定时器;Swoole底层定时任务是通过最小堆实现的,堆顶元素的超时时间最近;结构体_swTimer维护着Swoole内部所有的定时任务:

2023-03-25T16:05:30.png

当调用swTimer_add向_swTimer结构中添加定时任务时,需要更新_swTimer中最早的定时任务触发时间_next_msec,同时更新main_reactor对象的超时时间:

2023-03-25T16:06:10.png

函数wait_event负责将当前协程换出,直到注册的事件发生

2023-03-25T16:17:07.png

  • 函数add_event用于添加事件,底层调用reactor->add添加fd的监听事件;
  • read_co = co或者write_co = co,用于记录当前哪个协程阻塞在该socket对象上,当该socket对象的读写事件被触发时,可以恢复该协程执行;
  • 函数yield()将该协程换出;

上面提到,创建协程时,注册了一个函数swoole_event_wait,在生命周期register_shutdown阶段会执行该函数,开始Swoole的事件循环,阻挡了php生命周期的结束。函数swoole_event_wait底层就是调用main_reactor->wait等待fd读写事件的产生;我们以epoll为例讲述事件循环的逻辑:

2023-03-25T16:17:44.png

swReactorEpoll_wait是对函数epoll_wait的封装;当有读写事件发生时,执行相应的handle,根据上面的讲解我们知道读写事件的handle分别为readable_event_callback和writable_event_callback;

2023-03-25T16:17:57.png

可以看到函数readable_event_callback只是简单的恢复read_co协程即可;

当epoll_wait发生超时,最终调用的是函数swReactor_onTimeout,该函数会从Swoole维护的一系列定时任务swTimer中查找已经超时的定时任务,同时执行其callback回调;

2023-03-25T15:49:11.png

该callback回调函数即为上面设置的timer_callback:

2023-03-25T15:48:46.png

同样的,timer_callback函数只是简单的恢复read_co或者write_co协程即可

sleep定时器事件

Co::sleep()的实现函数为PHP_METHOD(swoole_coroutine_util, sleep),该函数通过调用Coroutine::sleep实现了协程休眠的功能:

2023-03-25T15:48:23.png

可以看到,与socket读写事件超时处理相同,sleep内部实现时通过swTimer_add添加定时任务,同时换出当前协程实现的。该定时任务会导致main_reactor对象的超时时间的改变,即修改了epoll_wait的超时时间。

sleep的超时处理函数为sleep_timeout,只需要换入该阻塞协程对象即可,实现如下:

2023-03-25T15:48:00.png